commit 9e4b70f1f1fbd3d5c603dc0157a6c10a2c213e17 Author: how2ice Date: Tue Apr 21 03:33:23 2026 +0900 Initial import diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..7bdfc8e --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +NODE_VERSION=22.22.2 +PHOTOPRISM_PORT=2342 +PHOTOPRISM_ORIGINALS_SOURCE=/mnt/usb/photos +PHOTOPRISM_SITE_URL=https://photo.sm-home.cloud/ +PHOTOPRISM_READONLY=true +PHOTOPRISM_ADMIN_USER=admin +PHOTOPRISM_ADMIN_PASSWORD=ChangeMe1234 +PHOTOPRISM_DATABASE_NAME=photoprism +PHOTOPRISM_DATABASE_USER=photoprism +PHOTOPRISM_DATABASE_PASSWORD=photoprism +PHOTOPRISM_DATABASE_ROOT_PASSWORD=photoprism-root diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..03a97b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +node_modules/ +dist/ +dev-dist/ +app-dist/ +test-app-dist/ + +.DS_Store +.auto_codex/ +.worktrees/ +.idea/ +.vscode/ +.docker/ + +coverage/ +playwright-report/ +test-results/ +.cache/ +tmp/ +node_modules.root-owned-backup/ + +.env +.env.* +.tmp +!.env.example + +# etc workspace +etc/**/.env +etc/**/node_modules/ +etc/**/dist/ +etc/**/.docker/ +etc/**/*.log +etc/**/.DS_Store + +*.tsbuildinfo +*.swp +*.root-owned-backup + +vite.config.js +vite.config.d.ts + +public/.codex_chat +.server-command-runner-heartbeat.json +docs/assets/worklogs/ diff --git a/.nvmrc b/.nvmrc new file mode 100755 index 0000000..db49bb1 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.22.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100755 index 0000000..02fff07 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,71 @@ +# ๐Ÿ“Œ AI ์ž‘์—… ์šด์˜ ๊ทœ์น™ (Codex + ๋กœ์ปฌ ์ž‘์—… ๊ธฐ์ค€) + +## ๐Ÿšจ ํ˜„์žฌ ์ ์šฉ ๋ชจ๋“œ (์ตœ์šฐ์„ ) + +ํ˜„์žฌ ์ด ์ €์žฅ์†Œ๋Š” **๋กœ์ปฌ ์ „์šฉ + main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ**๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +์ด ๋ฌธ์„œ์˜ ๋ชฉ์ ์€ ๋ณต์žกํ•œ ๋ธŒ๋žœ์น˜ ํ๋ฆ„๋ณด๋‹ค **ํ˜„์žฌ ์ฒดํฌ์•„์›ƒ๋œ ๋กœ์ปฌ `main`์—์„œ ๋ฐ”๋กœ ์ˆ˜์ •ํ•˜๊ณ  ์‹คํ–‰ํ•˜๋Š” ๊ธฐ์ค€**์„ ์šฐ์„  ์ ์šฉํ•˜๋Š” ๊ฒƒ์ด๋‹ค. + +### Codex / AI ๊ธฐ๋ณธ ๊ทœ์น™ + +* ์‚ฌ์šฉ์ž๊ฐ€ ๊ตฌํ˜„, ์ˆ˜์ •, ์‹คํ–‰, ์„ค์ • ๋ณ€๊ฒฝ, ๋ฌธ์„œ ์ˆ˜์ •, ์ฑ„ํŒ… ์‘๋‹ต ๊ฐœ์„ , ์ž‘์—… ๋ฉ”๋ชจ ๋ฐ˜์˜์„ ์š”์ฒญํ•˜๋ฉด **ํ˜„์žฌ ๋กœ์ปฌ `main`์—์„œ ๋ฐ”๋กœ ์ž‘์—…**ํ•œ๋‹ค +* ๋ธŒ๋ผ์šฐ์ € ํ™•์ธ, ํ™”๋ฉด ๊ฒ€์ฆ, ์ ‘์† ํ…Œ์ŠคํŠธ, ๊ธฐ๋ณธ ์ž‘์—… ๋„๋ฉ”์ธ์€ **`https://test.sm-home.cloud/` ํ•˜๋‚˜๋งŒ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉ**ํ•œ๋‹ค +* ๋ณ„๋„ ์ง€์‹œ๊ฐ€ ์—†์œผ๋ฉด `sm-home.cloud`, `rel.sm-home.cloud` ๊ฐ™์€ ๋‹ค๋ฅธ ์™ธ๋ถ€ ๋„๋ฉ”์ธ์€ ์ž‘์—… ๊ธฐ์ค€์œผ๋กœ ์‚ผ์ง€ ์•Š๋Š”๋‹ค +* `์ฑ„ํŒ…`, `์ž‘์—… ๋ฉ”๋ชจ`, `์ž‘์—…๋ฉ”๋ชจ`, `๋ฉ”๋ชจ ๋ฐ˜์˜`, `๋ฌธ์„œ ๋ฐ˜์˜` ์š”์ฒญ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ **๋ธŒ๋žœ์น˜ ์ƒ์„ฑ ์—†์ด main ์ง์ ‘ ์ˆ˜์ •**์œผ๋กœ ํ•ด์„ํ•œ๋‹ค +* `git remote`, `fetch`, `pull`, `push`, `branch`, `switch`, `checkout`, `merge`, `rebase`, `reset`, `stash`, `tag`, `commit` ๊ฐ™์€ Git ๊ด€๋ฆฌ ์ž‘์—…์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ Git ์ž‘์—…์„ **๋ช…์‹œ์ ์œผ๋กœ ์š”์ฒญํ•œ ๊ฒฝ์šฐ์—๋งŒ** ํ•„์š”ํ•œ ๋ช…๋ น์„ ์ตœ์†Œ ๋ฒ”์œ„๋กœ ์ˆ˜ํ–‰ํ•œ๋‹ค +* ์›๊ฒฉ ์ €์žฅ์†Œ ์—ฐ๊ฒฐ ๋ณต๊ตฌ, ๋ธŒ๋žœ์น˜ ์ „๋žต ๋ณต๊ตฌ, release/main ๋™๊ธฐํ™”, ์ž๋™ merge ๊ฐ™์€ ์ž‘์—…์„ ์ž๋™์œผ๋กœ ์‹œ๋„ํ•˜์ง€ ์•Š๋Š”๋‹ค +* ํ˜„์žฌ๋Š” ๋ธŒ๋žœ์น˜ ์ „๋žต๋ณด๋‹ค **๋กœ์ปฌ ์‹คํ–‰ ๊ฐ€๋Šฅ ์ƒํƒœ ์œ ์ง€, ์ฝ”๋“œ ์ˆ˜์ •, ๋ฌธ์„œ ๊ฐฑ์‹ , ๋ฉ”๋ชจ ๋ฐ˜์˜ ์†๋„**๋ฅผ ์šฐ์„ ํ•œ๋‹ค +* `.auto_codex` ๊ด€๋ จ Git ์ž๋™ํ™”, Plan ์ž๋™ํ™”, ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ/๋ณ‘ํ•ฉ ํ๋ฆ„์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋‹ค์‹œ ์š”์ฒญํ•˜๊ธฐ ์ „๊นŒ์ง€ **๋น„ํ™œ์„ฑ ์ƒํƒœ**๋กœ ์ทจ๊ธ‰ํ•œ๋‹ค + +### ์š”์ฒญ ํ•ด์„ ๊ทœ์น™ + +* ์‚ฌ์šฉ์ž๊ฐ€ ๋‹จ์ˆœํžˆ ๊ตฌํ˜„, ์ˆ˜์ •, ์‹คํ–‰, ์„ค์ • ๋ณ€๊ฒฝ์„ ์š”์ฒญํ•˜๋ฉด **Git ์ž‘์—… ์—†์ด ๋กœ์ปฌ ํŒŒ์ผ ์ˆ˜์ •๊ณผ ์‹คํ–‰ ์ž‘์—…๋งŒ ์ง„ํ–‰**ํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `git`, `๋ธŒ๋žœ์น˜`, `์›๊ฒฉ`, `push`, `pull`, `merge`, `commit`๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์–ธ๊ธ‰ํ•  ๋•Œ๋งŒ Git ์ž‘์—…์œผ๋กœ ํ•ด์„ํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `Plan ๋“ฑ๋ก`, `Plan ๊ฒŒ์‹œํŒ ๋“ฑ๋ก`, `๊ฒŒ์‹œํŒ ๋“ฑ๋ก`, `๊ณ„ํš ๋“ฑ๋ก`์ด๋ผ๊ณ  ๋งํ•˜๋ฉด **Plan ๊ฒŒ์‹œํŒ ํ•ญ๋ชฉ ์ƒ์„ฑ** ์˜๋ฏธ๋กœ ํ•ด์„ํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `์ž๋™ํ™” ๋“ฑ๋ก`, `์ž๋™ํ™” ์ ‘์ˆ˜`, `์ž๋™ํ™” ์‹คํ–‰`, `์ž๋™ํ™” ๋Œ๋ ค์ค˜`๋ผ๊ณ  ๋งํ•˜๋ฉด **์ž๋™ํ™” API ๋“ฑ๋ก ๋˜๋Š” ์‹ค์ œ ์ž๋™ํ™” ์ˆ˜ํ–‰** ์˜๋ฏธ๋กœ ํ•ด์„ํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `์ž๋™ํ™” ๋ฉ”๋ชจ`๋ผ๊ณ ๋งŒ ๋งํ•˜๋ฉด ๊ฒŒ์‹œํŒ ๋ฉ”๋ชจ์ธ์ง€ ์ž๋™ํ™” ์ ‘์ˆ˜ ๋ฉ”๋ชจ์ธ์ง€ ๋ฌธ๋งฅ์„ ๋จผ์ € ํ™•์ธํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `Plan ๊ฒŒ์‹œํŒ์— ๋“ฑ๋ก๋งŒ`, `์ž๋™ํ™” ์—†์ด`, `๊ตฌํ˜„ํ•˜์ง€ ๋ง๊ณ  ๊ณ„ํš๋งŒ ๋“ฑ๋ก`, `๊ฒŒ์‹œํŒ๋งŒ`์ฒ˜๋Ÿผ ํ‘œํ˜„ํ•˜๋ฉด **์ž๋™ํ™” ์‹คํ–‰ ์—†์ด Plan ํ•ญ๋ชฉ๋งŒ ๋“ฑ๋ก**ํ•œ๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ `์ž๋™ํ™”ํ•ด์ค˜`, `๊ตฌํ˜„ํ•ด์ค˜`, `์ž‘์—… ์ง„ํ–‰ํ•ด์ค˜`, `์‹คํ–‰ํ•ด์ค˜`์ฒ˜๋Ÿผ ์‹ค์ œ ์ˆ˜ํ–‰์„ ๋ช…์‹œํ•œ ๊ฒฝ์šฐ์—๋งŒ ์ž๋™ํ™” ๋˜๋Š” ์ฝ”๋“œ ์ž‘์—…์œผ๋กœ ํ•ด์„ํ•œ๋‹ค +* ์š”์ฒญ์ด ๋ชจํ˜ธํ•˜๋ฉด ์ž๋™ํ™” ์‹คํ–‰์„ ๋ฐ”๋กœ ์ง„ํ–‰ํ•˜์ง€ ๋ง๊ณ , ์šฐ์„  **Plan ๋“ฑ๋ก**์œผ๋กœ ์•ˆ์ „ ํ•ด์„ํ•˜๊ฑฐ๋‚˜ ์งง๊ฒŒ ์žฌํ™•์ธํ•œ๋‹ค + +--- + +## Git ๊ด€๋ จ ์•ˆ์ „ ๊ทœ์น™ + +* ๋กœ์ปฌ ์ž‘์—… ์ค‘์—๋„ ์‚ฌ์šฉ์ž๊ฐ€ ์š”์ฒญํ•˜์ง€ ์•Š์€ Git ์ •๋ฆฌ ์ž‘์—…์€ ํ•˜์ง€ ์•Š๋Š”๋‹ค +* `reset`, `checkout`, `switch`, `clean`, ๊ฐ•์ œ ๋ฎ์–ด์“ฐ๊ธฐ์ฒ˜๋Ÿผ ๋˜๋Œ๋ฆฌ๊ธฐ ์–ด๋ ค์šด ์ž‘์—…์€ ์ž๋™์œผ๋กœ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ Git ์ž‘์—…์„ ์š”์ฒญํ•ด๋„ **์ •๋ง ํ•„์š”ํ•œ ๋ฒ”์œ„๋งŒ** ์‹คํ–‰ํ•œ๋‹ค +* ํ˜„์žฌ ๋ชจ๋“œ์—์„œ๋Š” `main` ์ง์ ‘ ์ˆ˜์ •์ด ํ—ˆ์šฉ๋˜์ง€๋งŒ, **์ž๋™ commit / push๋Š” ์—ฌ์ „ํžˆ ๊ธˆ์ง€**ํ•œ๋‹ค + +--- + +## Codex Live / ์ฑ„ํŒ… / ์ž‘์—… ๋ฉ”๋ชจ ๊ทœ์น™ + +* `Codex Live`, ์ผ๋ฐ˜ ์ฑ„ํŒ…, ์ž‘์—… ๋ฉ”๋ชจ ๋ฐ˜์˜ ์š”์ฒญ์€ ๋ชจ๋‘ ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์˜ ๋กœ์ปฌ `main`์„ ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค +* ์™ธ๋ถ€ ๋„๋ฉ”์ธ ๊ธฐ์ค€ ๋™์ž‘ ํ™•์ธ์ด ํ•„์š”ํ•˜๋ฉด ๊ธฐ๋ณธ ๋Œ€์ƒ์€ ํ•ญ์ƒ `https://test.sm-home.cloud/`๋กœ ๋ณธ๋‹ค +* ์ฑ„ํŒ…์—์„œ ๋‚˜์˜จ ์ˆ˜์ • ์š”์ฒญ๋„ ๋ณ„๋„ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ ์—†์ด ๋ฐ”๋กœ ํŒŒ์ผ ์ˆ˜์ •์œผ๋กœ ์ด์–ด์งˆ ์ˆ˜ ์žˆ๋‹ค +* ์ž‘์—… ๋ฉ”๋ชจ๋Š” ๊ธฐ๋ก ๋ชฉ์ ์ด๋“  ์‹ค์ œ ์ˆ˜์ • ์ง€์‹œ๋“  ์šฐ์„  `main` ๊ธฐ์ค€์˜ ๋กœ์ปฌ ์ž‘์—…์œผ๋กœ ์—ฐ๊ฒฐํ•œ๋‹ค +* ์ฑ„ํŒ…๊ณผ ์ž‘์—… ๋ฉ”๋ชจ๋Š” Git flow๋ฅผ ๊ฐ•์ œํ•˜์ง€ ์•Š๊ณ , ํ•„์š”ํ•œ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉ์ž๊ฐ€ ๋ณ„๋„๋กœ Git ๋‹จ๊ณ„๋ฅผ ์š”์ฒญํ•œ๋‹ค +* ์ฑ„ํŒ…์—์„œ ํŒŒ์ผ/๋ฌธ์„œ/์ด๋ฏธ์ง€/์ฝ”๋“œ ๋ฆฌ์†Œ์Šค๋ฅผ ์ œ๊ณตํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ `public/.codex_chat//resource/` ์•„๋ž˜ ์„ธ์…˜ ์ „์šฉ ๊ฒฝ๋กœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค +* ์ฑ„ํŒ… ์ฒจ๋ถ€ ํŒŒ์ผ์€ `public/.codex_chat//resource/uploads/` ์•„๋ž˜ ๊ฒฝ๋กœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค +* ์›๋ณธ ํŒŒ์ผ ๊ฒฝ๋กœ๋งŒ ์‘๋‹ตํ•ด๋„ ์„œ๋ฒ„๊ฐ€ ํ•ด๋‹น ๋ฆฌ์†Œ์Šค๋ฅผ ์œ„ ์„ธ์…˜ ๊ฒฝ๋กœ๋กœ ๋ณต์‚ฌํ•ด ๊ณต๊ฐœ ๋งํฌ๋กœ ๋ฐ”๊ฟ” ์ค„ ์ˆ˜ ์žˆ์ง€๋งŒ, ๋ฌธ์„œ์™€ ์•ˆ๋‚ด ๋ฌธ๊ตฌ์—๋Š” ์œ„ ๊ณต๊ฐœ ๊ฒฝ๋กœ ๊ธฐ์ค€์„ ์šฐ์„  ๋ช…์‹œํ•œ๋‹ค + +--- + +## Plan / ์ž๋™ํ™” ๋ฉ”๋ชจ + +* ๊ธฐ์กด ๋ฌธ์„œ์— ๋‚จ์•„ ์žˆ๋Š” `feature`, `hotfix`, `release` ํ๋ฆ„์€ **ํ˜„์žฌ ๋กœ์ปฌ ๋ชจ๋“œ์—์„œ๋Š” ๊ธฐ๋ณธ ๊ทœ์น™์œผ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค** +* Plan ๊ฒŒ์‹œํŒ๊ณผ ์ž๋™ํ™” ๊ด€๋ จ ๊ธฐ๋Šฅ ์„ค๋ช…์€ UI/์ƒํƒœ ์„ค๋ช…์œผ๋กœ๋งŒ ์ฐธ๊ณ ํ•˜๊ณ , ์‹ค์ œ Git ๋ธŒ๋žœ์น˜ ์šด์˜ ๊ทœ์น™์œผ๋กœ ์ž๋™ ์ ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค +* ์‚ฌ์šฉ์ž๊ฐ€ ๋‚˜์ค‘์— ๋ธŒ๋žœ์น˜ ์ „๋žต ๋ณต๊ตฌ๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์š”์ฒญํ•˜๋ฉด ๊ทธ๋•Œ ๋ณ„๋„ ๋ฌธ์„œ ๊ฐฑ์‹  ํ›„ ๋‹ค์‹œ ์ ์šฉํ•œ๋‹ค + +--- + +## ํ•œ ์ค„ ์š”์•ฝ + +๐Ÿ‘‰ ์ง€๊ธˆ์€ ๋กœ์ปฌ `main`์—์„œ ๋ฐ”๋กœ ์ˆ˜์ •ํ•œ๋‹ค +๐Ÿ‘‰ ์™ธ๋ถ€ ํ™•์ธ ๊ธฐ๋ณธ ๋„๋ฉ”์ธ์€ `https://test.sm-home.cloud/` ํ•˜๋‚˜๋‹ค +๐Ÿ‘‰ ์ฑ„ํŒ…์ด๋“  ์ž‘์—… ๋ฉ”๋ชจ๋“  ๊ธฐ๋ณธ ํ•ด์„์€ `main` ์ง์ ‘ ์ž‘์—…์ด๋‹ค +๐Ÿ‘‰ Git ์ž‘์—…์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์š”์ฒญํ•  ๋•Œ๋งŒ ์ตœ์†Œ ๋ฒ”์œ„๋กœ ํ•œ๋‹ค + +--- diff --git a/README.md b/README.md new file mode 100755 index 0000000..56ea6e9 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# AI Code App + +React + Vite + TypeScript ๊ธฐ๋ฐ˜์˜ ๋ฌธ์„œ/์ƒ˜ํ”Œ ํ—ˆ๋ธŒ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ž…๋‹ˆ๋‹ค. ํ˜„์žฌ ์ €์žฅ์†Œ๋Š” ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ, ์œ„์ ฏ ์ƒ˜ํ”Œ, Markdown ๋ฌธ์„œ ๋ทฐ์–ด, Plan ๊ฒŒ์‹œํŒ์„ ํ•˜๋‚˜์˜ ์•ฑ์—์„œ ํƒ์ƒ‰ํ•  ์ˆ˜ ์žˆ๋„๋ก ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +## ์ž„์‹œ ์šด์˜ ๋ฉ”๋ชจ + +- ํ˜„์žฌ ์ €์žฅ์†Œ๋Š” ๋‹น๋ถ„๊ฐ„ **๋กœ์ปฌ ์ „์šฉ ์ž‘์—… ๋ชจ๋“œ**๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- Git ์›๊ฒฉ ๋™๊ธฐํ™”, ๋ธŒ๋žœ์น˜ ์šด์˜, ์ž๋™ ๋ณ‘ํ•ฉ/์ž๋™ํ™”๋Š” ์ž ์‹œ ์ค‘์ง€ํ•œ ์ƒํƒœ๋กœ ๊ฐ„์ฃผํ•ฉ๋‹ˆ๋‹ค. +- Codex๋‚˜ ์ž๋™ํ™” ๋„๊ตฌ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ Git ์ž‘์—… ์—†์ด **ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ `main` ์ž‘์—…๋ณธ์„ ๋ฐ”๋กœ ์ˆ˜์ •**ํ•ฉ๋‹ˆ๋‹ค. +- `์ฑ„ํŒ…`, `Codex Live`, `์ž‘์—…๋ฉ”๋ชจ`, `๋ฉ”๋ชจ ๋ฐ˜์˜` ์š”์ฒญ๋„ ๊ฐ™์€ ๊ธฐ์ค€์œผ๋กœ ํ•ด์„ํ•ฉ๋‹ˆ๋‹ค. +- ์ฑ„ํŒ… ๋ฆฌ์†Œ์Šค์™€ ์ฒจ๋ถ€ ํŒŒ์ผ์€ `public/.codex_chat//resource/...` ๊ธฐ์ค€์œผ๋กœ ์ œ๊ณตํ•˜๋ฉฐ, ์—…๋กœ๋“œ ํŒŒ์ผ์€ `public/.codex_chat//resource/uploads/...` ์•„๋ž˜๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +## ์‹œ์ž‘ํ•˜๊ธฐ + +```bash +npm install +npm run dev +``` + +## PhotoPrism + +๋ฃจํŠธ `docker-compose.yml`์—๋Š” PhotoPrism์™€ MariaDB ์„œ๋น„์Šค๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +```bash +cp .env.example .env +docker compose up -d photoprism photoprism-db +``` + +- ๊ธฐ๋ณธ ์ ‘์† ํฌํŠธ: `127.0.0.1:2342` +- ๊ธฐ๋ณธ ์‚ฌ์ดํŠธ URL: `https://photo.sm-home.cloud/` +- ์›๋ณธ ์‚ฌ์ง„ ๊ฒฝ๋กœ: `/mnt/usb/photos` +- ์›๋ณธ ๊ฒฝ๋กœ๋Š” ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ๋งˆ์šดํŠธ๋ฉ๋‹ˆ๋‹ค. +- `/mnt/usb/photos`๊ฐ€ ํ˜ธ์ŠคํŠธ์— ์—†์œผ๋ฉด bind mount๋ฅผ ์ž๋™ ์ƒ์„ฑํ•˜์ง€ ์•Š๊ณ  ๊ธฐ๋™์ด ์‹คํŒจํ•ฉ๋‹ˆ๋‹ค. + +## ์ฃผ์š” ์Šคํฌ๋ฆฝํŠธ + +```bash +npm run dev +npm run build +npm run build:lib +npm run build:app +npm run preview +npm run docs:daily +npm run capture:component +npm run capture:menu +npm run capture:feature +npm run capture:fullscreen +npm run capture:plan-mobile +npm run plan:codex:once +``` + +## ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ + +```text +src/ +โ”œโ”€ app/ +โ”‚ โ”œโ”€ main/ # ๋ฉ”์ธ ๋ ˆ์ด์•„์›ƒ๊ณผ ์ƒ๋‹จ/์‚ฌ์ด๋“œ UI +โ”‚ โ””โ”€ manifests/ # ๋ฌธ์„œ/์ƒ˜ํ”Œ ๋กœ๋”ฉ ๋งค๋‹ˆํŽ˜์ŠคํŠธ +โ”œโ”€ components/ +โ”‚ โ”œโ”€ markdownPreview/ # Markdown ๋ฌธ์„œ ๋ชฉ๋ก/์นด๋“œ ๋ Œ๋”๋ง +โ”‚ โ”œโ”€ navigation/ # ์„น์…˜ ๋ฉ”๋‰ด์™€ ํด๋” ํŠธ๋ฆฌ +โ”‚ โ”œโ”€ previewer/ # text/json/code/image/markdown ๋ฏธ๋ฆฌ๋ณด๊ธฐ +โ”‚ โ”œโ”€ search/ # ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ +โ”‚ โ”œโ”€ status-badge/ # ์ƒํƒœ ํ‘œํ˜„ UI +โ”‚ โ””โ”€ window/ # ๋“œ๋ž˜๊ทธ/๋ฆฌ์‚ฌ์ด์ฆˆ ๊ฐ€๋Šฅํ•œ ์œˆ๋„์šฐ UI +โ”œโ”€ features/ +โ”‚ โ”œโ”€ dashboard/ # ํ”„๋กœ์ ํŠธ ์ „์šฉ ๋Œ€์‹œ๋ณด๋“œ ์ƒ˜ํ”Œ +โ”‚ โ”œโ”€ layout/ # ํ”„๋กœ์ ํŠธ ์ „์šฉ ๋ ˆ์ด์•„์›ƒ ๋ฌธ์„œ +โ”‚ โ”œโ”€ markdownPreview/ # ๊ธฐ๋Šฅ ๋ ˆ๋ฒจ Markdown ์นด๋“œ +โ”‚ โ””โ”€ planBoard/ # Plan ๊ฒŒ์‹œํŒ ํ™”๋ฉด๊ณผ API ์—ฐ๋™ +โ”œโ”€ layer/ # ์ œ์Šค์ฒ˜/๊ฒ€์ƒ‰ ๋ ˆ์ด์–ด +โ”œโ”€ samples/ # ์ƒ˜ํ”Œ ์—”ํŠธ๋ฆฌ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ +โ”œโ”€ store/ # ์•ฑ ์ „์—ญ ์ƒํƒœ +โ””โ”€ widgets/ # ์œ„์ ฏ ๋‹จ์œ„ ์ƒ˜ํ”Œ๊ณผ ๊ณตํ†ต ์…ธ +docs/ +โ”œโ”€ components/ # ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ +โ”œโ”€ features/ # ๊ธฐ๋Šฅ ๋ฌธ์„œ +โ”œโ”€ templates/ # ๋ฌธ์„œ ํ…œํ”Œ๋ฆฟ +โ””โ”€ worklogs/ # ๋‚ ์งœ๋ณ„ ์ž‘์—…์ผ์ง€ +``` + +## ์•ฑ ๊ตฌ์„ฑ + +- `APIs / Components`: ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ ํƒ์ƒ‰ +- `APIs / Widgets`: ์œ„์ ฏ ์ƒ˜ํ”Œ ํƒ์ƒ‰ +- `Docs`: `docs/**/*.md`์™€ ์ผ๋ถ€ `src/features/**/*.md` ๋ฌธ์„œ ํƒ์ƒ‰ +- `Plans`: ์ž‘์—… ํ•ญ๋ชฉ, ์กฐ์น˜ ์ด๋ ฅ, ์ด์Šˆ ์ด๋ ฅ์„ ๊ด€๋ฆฌํ•˜๋Š” Plan ๊ฒŒ์‹œํŒ + +## ๋ฌธ์„œ ์œ„์น˜ + +- ์ „์ฒด ๋ฌธ์„œ ๊ฐ€์ด๋“œ: `docs/README.md` +- ์ž‘์—…์ผ์ง€: `docs/worklogs` +- ๊ธฐ๋Šฅ ๋ฌธ์„œ: `docs/features` +- ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ: `docs/components` + +## ์šด์˜ ๋ฉ”๋ชจ + +- ์•ฑ ๋ฌธ์„œ๋Š” Vite `import.meta.glob`์œผ๋กœ Markdown ํŒŒ์ผ์„ ์ˆ˜์ง‘ํ•ฉ๋‹ˆ๋‹ค. +- ์ž‘์—…์ผ์ง€๋Š” ๋‚ ์งœ๋ณ„ ํŒŒ์ผ๋กœ ๋ˆ„์ ํ•˜๋ฉฐ ์บก์ฒ˜ ์ด๋ฏธ์ง€๋Š” `docs/assets/worklogs/YYYY-MM-DD/` ๊ธฐ์ค€์œผ๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- Plan ์ž๋™ํ™” ์Šคํฌ๋ฆฝํŠธ๋Š” `scripts/run-plan-codex-once.mjs`๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- ์„œ๋ฒ„ ์žฌ๊ธฐ๋™์„ ํ˜ธ์ŠคํŠธ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด `npm run server-command:runner`๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฌธ์„œ/์ž‘์—…์ผ์ง€ ์ผ์ผ ์ •๋ฆฌ๋Š” `npm run docs:daily`์™€ `.github/workflows/daily-docs-maintenance.yml` ๊ธฐ์ค€์œผ๋กœ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +## ํ”„๋กœ์ ํŠธ ํ˜„ํ™ฉ + + +- ๊ธฐ์ค€ ์ผ์ž: `2026-04-07` (Asia/Seoul) +- ์ž‘์—…์ผ์ง€: 9๊ฐœ +- ๊ธฐ๋Šฅ ๋ฌธ์„œ: 2๊ฐœ +- ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ: 8๊ฐœ +- ์Šคํฌ๋ฆฐ์ƒท ๋ณด๊ด€ ํด๋”: 7๊ฐœ + +์ตœ๊ทผ ์ž‘์—…์ผ์ง€ +- `2026-04-07` ์ž‘์—…์ผ์ง€ +- `2026-04-06` ์ž‘์—…์ผ์ง€ +- `2026-04-05` ์ž‘์—…์ผ์ง€ +- `2026-04-04` ์ž‘์—…์ผ์ง€ +- `2026-04-03` ์ž‘์—…์ผ์ง€ +- `2026-04-02` ์ž‘์—…์ผ์ง€ +- `2026-04-01` ์ž‘์—…์ผ์ง€ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..2d34c8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,152 @@ +services: + photoprism: + image: photoprism/photoprism:latest + container_name: photoprism + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + depends_on: + - photoprism-db + ports: + - '127.0.0.1:${PHOTOPRISM_PORT:-2342}:2342' + volumes: + - type: bind + source: ${PHOTOPRISM_ORIGINALS_SOURCE:-/mnt/usb/photos} + target: /photoprism/originals + read_only: true + bind: + create_host_path: false + - photoprism-storage:/photoprism/storage + environment: + PHOTOPRISM_ADMIN_USER: ${PHOTOPRISM_ADMIN_USER:-admin} + PHOTOPRISM_ADMIN_PASSWORD: ${PHOTOPRISM_ADMIN_PASSWORD:-ChangeMe1234} + PHOTOPRISM_SITE_URL: ${PHOTOPRISM_SITE_URL:-https://photo.sm-home.cloud/} + PHOTOPRISM_ORIGINALS_PATH: /photoprism/originals + PHOTOPRISM_STORAGE_PATH: /photoprism/storage + PHOTOPRISM_READONLY: ${PHOTOPRISM_READONLY:-true} + PHOTOPRISM_DATABASE_DRIVER: mysql + PHOTOPRISM_DATABASE_SERVER: photoprism-db:3306 + PHOTOPRISM_DATABASE_NAME: ${PHOTOPRISM_DATABASE_NAME:-photoprism} + PHOTOPRISM_DATABASE_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism} + PHOTOPRISM_DATABASE_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism} + restart: unless-stopped + networks: + - default + - work-backend + + photoprism-db: + image: mariadb:11 + container_name: photoprism-db + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + command: --innodb-buffer-pool-size=512M --transaction-isolation=READ-COMMITTED --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + environment: + MARIADB_AUTO_UPGRADE: "1" + MARIADB_DATABASE: ${PHOTOPRISM_DATABASE_NAME:-photoprism} + MARIADB_USER: ${PHOTOPRISM_DATABASE_USER:-photoprism} + MARIADB_PASSWORD: ${PHOTOPRISM_DATABASE_PASSWORD:-photoprism} + MARIADB_ROOT_PASSWORD: ${PHOTOPRISM_DATABASE_ROOT_PASSWORD:-photoprism-root} + volumes: + - photoprism-db:/var/lib/mysql + restart: unless-stopped + networks: + - default + - work-backend + + prod-app: + image: node:${NODE_VERSION:-22.22.2}-bookworm + container_name: ai-code-app-prod + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + user: "0:0" + cpus: 1.0 + mem_limit: 1536m + working_dir: /prod-app + ports: + - '127.0.0.1:5173:5173' + volumes: + - ${PROD_APP_SOURCE:-.}:/prod-app + - ./.docker/prod-app/node_modules:/prod-app/node_modules + - ./.docker/prod-app/home:/home/how2ice + networks: + - default + - work-backend + environment: + HOME: /home/how2ice + NPM_CONFIG_CACHE: /home/how2ice/.npm + WORK_SERVER_URL: http://work-server:3100 + VITE_DISABLE_APP_UPDATE: "true" + command: > + sh -c "npm ci --legacy-peer-deps && npm run build:test-app && npm run preview:test-app -- --host 0.0.0.0 --port 5173 --strictPort" + + app: + image: node:${NODE_VERSION:-22.22.2}-bookworm + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + user: "0:0" + cpus: 1.0 + mem_limit: 1536m + working_dir: /app + ports: + - '127.0.0.1:5174:5173' + volumes: + - ./:/app + - app-node-modules:/app/node_modules + - app-home:/home/how2ice + networks: + - default + - work-backend + environment: + HOME: /home/how2ice + NPM_CONFIG_CACHE: /home/how2ice/.npm + WORK_SERVER_URL: http://work-server:3100 + command: > + sh -c "npm ci --legacy-peer-deps && npm run build:test-app && npm run preview:test-app -- --host 0.0.0.0 --port 5173 --strictPort" + + release-app: + image: node:${NODE_VERSION:-22.22.2}-bookworm + container_name: ai-code-app-release + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + user: "0:0" + cpus: 1.0 + mem_limit: 1536m + working_dir: /release-app + ports: + - '127.0.0.1:5175:5173' + volumes: + - ${RELEASE_APP_SOURCE:-.}:/release-app + - ./.docker/release-app/node_modules:/release-app/node_modules + - ./.docker/release-app/home:/home/how2ice + networks: + - default + - work-backend + environment: + HOME: /home/how2ice + NPM_CONFIG_CACHE: /home/how2ice/.npm + command: > + sh -c "npm ci --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 5173" + +networks: + work-backend: + name: work-backend + +volumes: + app-node-modules: + app-home: + photoprism-storage: + photoprism-db: diff --git a/docs/README.md b/docs/README.md new file mode 100755 index 0000000..6f2415b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,155 @@ +# Docs Guide + +ํ”„๋กœ์ ํŠธ ๋ฌธ์„œ๋Š” ์ž‘์—…์ผ์ง€, ๊ธฐ๋Šฅ ๋ฌธ์„œ, ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋ณธ ์ถ•์œผ๋กœ ์šด์˜ํ•ฉ๋‹ˆ๋‹ค. ํ˜„์žฌ ๋ฉ”์ธ ์•ฑ `Docs` ํ™”๋ฉด์€ `docs/**/*.md`๋ฅผ ๋™์ ์œผ๋กœ ์ˆ˜์ง‘ํ•ด ํด๋”๋ณ„๋กœ ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค. + +## 0. ์ž„์‹œ ๋กœ์ปฌ ๋ชจ๋“œ + +- ํ˜„์žฌ ์ €์žฅ์†Œ๋Š” ๋‹น๋ถ„๊ฐ„ ๋กœ์ปฌ ์ „์šฉ์œผ๋กœ ์šด์˜ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฌธ์„œ ์ •๋ฆฌ๋‚˜ Codex ์ž‘์—… ์‹œ Git ์›๊ฒฉ ๋™๊ธฐํ™”, ๋ธŒ๋žœ์น˜ ์šด์˜, ์ž๋™ merge ํ๋ฆ„์€ ๊ธฐ๋ณธ ์ „์ œ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. +- `Codex Live`, ์ผ๋ฐ˜ ์ฑ„ํŒ…, ์ž‘์—…๋ฉ”๋ชจ ๋ฐ˜์˜ ์š”์ฒญ์€ **ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ๋กœ์ปฌ `main` ์ž‘์—…๋ณธ์„ ๋ฐ”๋กœ ์ˆ˜์ •**ํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- Git ๊ด€๋ จ ์ž‘์—…์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์š”์ฒญํ•  ๋•Œ๋งŒ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +## 1. ์ž‘์—…์ผ์ง€ + +- ์œ„์น˜: `docs/worklogs` +- ๊ทœ์น™: ๋‚ ์งœ๋ณ„ 1๊ฐœ Markdown ํŒŒ์ผ ์ž‘์„ฑ +- ํŒŒ์ผ๋ช… ์˜ˆ์‹œ: `2026-03-31.md` +- ํ…œํ”Œ๋ฆฟ: `docs/templates/worklog-template.md` +- ๊ถŒ์žฅ ๊ธฐ๋ก ๋ฒ”์œ„: ๊ตฌํ˜„ ๋‚ด์šฉ, ๊ตฌ์กฐ ๋ณ€๊ฒฝ, ๋นŒ๋“œ/๋ฐฐํฌ ์ด์Šˆ, Git ์ž‘์—… ๋‚ด์—ญ +- ์ตœ๊ทผ ์ž‘์—…์ผ์ง€๋Š” ๋‚ ์งœ๋ณ„๋กœ ๊ณ„์† ๋ˆ„์  ๊ธฐ๋ก +- ํ™”๋ฉด ์บก์ฒ˜๋Š” `docs/assets/worklogs/YYYY-MM-DD/` ์•„๋ž˜์— ์ €์žฅํ•˜๊ณ  ์ž‘์—…์ผ์ง€์—์„œ ์ƒ๋Œ€ ๊ฒฝ๋กœ๋กœ ์—ฐ๊ฒฐ +- ์บก์ฒ˜๋Š” ์ „์ฒด ํ™”๋ฉด๋ณด๋‹ค ์ž‘์—…ํ•œ ์ปดํฌ๋„ŒํŠธ ์˜์—ญ ๋‹จ์œ„ ์ด๋ฏธ์ง€๋ฅผ ์šฐ์„  ์‚ฌ์šฉ +- ๋ฉ”๋‰ด/๊ธฐ๋Šฅ ์ฆ์ ์ด ํ•„์š”ํ•˜๋ฉด `capture:menu`, `capture:feature` ์Šคํฌ๋ฆฝํŠธ๋กœ ํ™”๋ฉด ๋‹จ์œ„ ์บก์ฒ˜๋ฅผ ํ•จ๊ป˜ ๋‚จ๊น€ +- ํ™”๋ฉด ์บก์ฒ˜๋ฅผ ๋‚จ๊ธฐ์ง€ ๋ชปํ•œ ๋‚ ์—๋„ `## ํ™”๋ฉด ์บก์ฒ˜` ์„น์…˜์€ ์œ ์ง€ํ•˜๊ณ , ๋ฏธ์ฒจ๋ถ€ ์‚ฌ์œ ๋ฅผ ํ•œ ์ค„๋กœ ๊ธฐ๋ก +- ๋ฌธ์„œ ์ตœ์‹ ํ™” ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•œ ๋‚ ์—๋Š” ์–ด๋–ค ๋ฌธ์„œ๋ฅผ ์™œ ์ˆ˜์ •ํ–ˆ๋Š”์ง€ ํ•จ๊ป˜ ๊ธฐ๋ก + +๊ถŒ์žฅ ํ•ญ๋ชฉ: + +- ์˜ค๋Š˜ ์ž‘์—…ํ•œ ๋‚ด์šฉ +- ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ ๊ณผ์ • +- ๊ฒฐ์ • ์‚ฌํ•ญ +- ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +## 2. ๊ธฐ๋Šฅ ๋ฌธ์„œ + +- ์œ„์น˜: `docs/features` +- ๊ทœ์น™: ๊ธฐ๋Šฅ ๋‹จ์œ„๋กœ Markdown ํŒŒ์ผ ์ž‘์„ฑ +- ํŒŒ์ผ๋ช… ์˜ˆ์‹œ: `auth.md`, `dashboard.md` +- ํ…œํ”Œ๋ฆฟ: `docs/templates/feature-template.md` +- ๊ถŒ์žฅ ๊ธฐ๋ก ๋ฒ”์œ„: ๊ธฐ๋Šฅ ๋ชฉ์ , ํ™”๋ฉด ํ๋ฆ„, API/์ƒํƒœ, ํ…Œ์ŠคํŠธ ํฌ์ธํŠธ +- `docs/features/*.md`๋ฅผ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•˜๋ฉด ์•ฑ `Docs / ๊ธฐ๋Šฅ๋ฌธ์„œ` ๋ฉ”๋‰ด์— ๋ฐ˜์˜๋จ +- `src/features/**/*.md`๋Š” ํ”„๋กœ์ ํŠธ ๋‚ด๋ถ€ ์ „์šฉ ์„ค๋ช… ๋ฌธ์„œ์šฉ์ด๋ฉฐ ๋ฉ”์ธ `Docs` ๋ฉ”๋‰ด์˜ ๊ธฐ๋ณธ ์ˆ˜์ง‘ ๋Œ€์ƒ์€ ์•„๋‹˜ + +๊ถŒ์žฅ ํ•ญ๋ชฉ: + +- ๊ธฐ๋Šฅ ๋ชฉ์  +- ์ฃผ์š” ํ™”๋ฉด/ํ๋ฆ„ +- ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ๋ฐ API +- ์˜ˆ์™ธ ์ฒ˜๋ฆฌ +- ํ…Œ์ŠคํŠธ ํฌ์ธํŠธ + +## 3. ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ + +- ์œ„์น˜: `docs/components` +- ๊ทœ์น™: ์ปดํฌ๋„ŒํŠธ๋ณ„ 1๊ฐœ Markdown ํŒŒ์ผ ์ž‘์„ฑ +- ํŒŒ์ผ๋ช… ์˜ˆ์‹œ: `status-badge.md`, `user-card.md` +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ: ๊ฐ ์ปดํฌ๋„ŒํŠธ์˜ `samples/Sample.tsx` +- ํ™•์žฅ ์ƒ˜ํ”Œ: `samples/*.tsx` + +๊ถŒ์žฅ ํ•ญ๋ชฉ: + +- ๋ชฉ์  +- ํด๋” ๊ตฌ์กฐ +- UI props +- plugin input/output ๊ทœ์น™ +- plugin ํ•ฉ์„ฑ ๊ทœ์น™ +- Sample ํ™œ์šฉ ์˜ˆ์‹œ + +ํ˜„์žฌ ๊ธฐ์ค€ ์ฃผ์š” ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ: + +```text +src/components +โ”œโ”€ markdownPreview +โ”œโ”€ navigation +โ”œโ”€ previewer +โ”œโ”€ search +โ”œโ”€ status-badge +โ””โ”€ window +``` + +๊ณตํ†ต ํ”Œ๋Ÿฌ๊ทธ์ธ ํƒ€์ž…๊ณผ ์œ ํ‹ธ์€ `src/types/component-plugin.ts` ์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +์ƒ˜ํ”Œ ์šด์˜ ๊ทœ์น™: + +- `samples/Sample.tsx`๋Š” ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ์˜ ๊ฐ€์žฅ ๊ธฐ๋ณธํ˜•๋งŒ ํ‘œํ˜„ +- plugin/feature ์˜ˆ์‹œ๋Š” `samples/*.tsx`๋กœ ๋ถ„๋ฆฌ +- ์ƒ˜ํ”Œ ๋ชฉ๋ก์—์„œ๋Š” ๊ฐ™์€ ์ปดํฌ๋„ŒํŠธ ๊ธฐ์ค€์œผ๋กœ ๋ฌถ๊ณ  `base -> plugin -> feature` ์ˆœ์„œ๋กœ ์ •๋ ฌ + +## 4. ์ƒ˜ํ”Œ/์œ„์ ฏ ๋ ˆ์ด์•„์›ƒ + +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ ๋ ˆ์ด์•„์›ƒ: ์ขŒ์ธก ์ปดํฌ๋„ŒํŠธ ๋ชฉ๋ก + ์šฐ์ธก ์ƒ์„ธ ์นด๋“œ +- ์ƒ์„ธ ์นด๋“œ๋Š” ์ปดํฌ๋„ŒํŠธ ํ•˜๋‚˜๋‹น 1๊ฐœ +- ์นด๋“œ ๋‚ด๋ถ€๋Š” `Base Sample` ์•„๋ž˜์— `Plugin Samples`, `Feature Samples`๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ๋ฐฐ์น˜ +- ์œ„์ ฏ ์ƒ˜ํ”Œ์€ `widgets/**/samples/*.tsx` ๊ธฐ์ค€์œผ๋กœ ๋ณ„๋„ ์ˆ˜์ง‘ +- ์‹ค์ œ ์ƒ˜ํ”Œ ์—”ํŠธ๋ฆฌ ๋กœ๋”ฉ์€ `src/app/manifests/samples.manifest.ts`, `src/samples/registry.ts`๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋™์ž‘ + +## 5. ํ”„๋กœ์ ํŠธ ์ข…์† ๋ ˆ์ด์•„์›ƒ + +- ์œ„์น˜: `src/features/layout` +- ๋Œ€์ƒ: ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ™”๋ฉด์—์„œ๋งŒ ์‚ฌ์šฉํ•˜๋Š” ๋ ˆ์ด์•„์›ƒ +- ์˜ˆ์‹œ: ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ ๋ชฉ๋ก, ์œ„์ ฏ ์ƒ˜ํ”Œ ๋ชฉ๋ก, Docs markdown preview, Plan ๊ฒŒ์‹œํŒ + +ํ”„๋กœ์ ํŠธ ์ข…์† ๊ธฐ๋Šฅ ๊ทœ์น™: + +- ํ˜„์žฌ ํ”„๋กœ์ ํŠธ์—์„œ๋งŒ ์˜๋ฏธ ์žˆ๋Š” ํ™”๋ฉด/๊ธฐ๋Šฅ์€ `src/features` ์•„๋ž˜์— ๋‘  +- ์˜ˆ: `Plan ๊ฒŒ์‹œํŒ`, ๋Œ€์‹œ๋ณด๋“œ feature ์ƒ˜ํ”Œ, ์•ฑ ์ „์šฉ ๋ ˆ์ด์•„์›ƒ +- ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ์œผ๋กœ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ•ญ๋ชฉ์€ `src/components`, `src/widgets`์— ์œ ์ง€ + +๋ฉ”์ธ ํ™”๋ฉด ๋ถ„๋ฆฌ ๊ทœ์น™: + +- ์œ„์น˜: `src/app/main` +- ๊ตฌ์„ฑ: `MainView`, `MainHeader`, `MainSidebar`, `MainContent` +- ๋ชฉ์ : ์ƒ๋‹จ ๋ฉ”๋‰ด, ์‚ฌ์ด๋“œ๋ฐ”, ๋ณธ๋ฌธ, ๊ฒ€์ƒ‰/๋ฌธ์„œ/Plan ํ๋ฆ„์„ ์•ฑ ๋ ˆ๋ฒจ์—์„œ ๋ถ„๋ฆฌ + +## 6. Markdown Preview + +- ๊ณตํ†ต markdown preview๋Š” `src/components/markdownPreview` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- `basePath`๋ฅผ ๋ฐ›์•„ ํŠน์ • ํด๋” ์•„๋ž˜ markdown ๋ฌธ์„œ๋ฅผ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ Œ๋”๋ง +- `docs` ๋ฌธ์„œ ์˜์—ญ์€ ์ขŒ์ธก ํด๋”/๋ฌธ์„œ ํŠธ๋ฆฌ + ์šฐ์ธก markdown ์นด๋“œ ๋ชฉ๋ก ๊ตฌ์กฐ ์‚ฌ์šฉ +- ๋ฌธ์„œ ์ˆ˜์ง‘ ๋งค๋‹ˆํŽ˜์ŠคํŠธ๋Š” `src/app/manifests/docs.manifest.ts`์—์„œ ๊ด€๋ฆฌ +- `docs/features`, `docs/components`, `docs/worklogs`, `docs/templates`๋Š” ํด๋” ๋‹จ์œ„๋กœ ์ž๋™ ๋ถ„๋ฅ˜๋จ +- `docs/worklogs`๋Š” ์ตœ์‹  ๋‚ ์งœ๊ฐ€ ๋จผ์ € ๋ณด์ด๋„๋ก ์—ญ์ˆœ ์ •๋ ฌ + +## 7. ๋Œ€์‹œ๋ณด๋“œ ์œ„์ ฏ/๋ฐ์ดํ„ฐ + +- ๋Œ€์‹œ๋ณด๋“œ ์นด๋“œ ์œ„์ ฏ์€ `src/widgets/dashboard-report-card` +- ์œ„์ ฏ ์ƒ˜ํ”Œ๊ณผ ํ”„๋กœ์ ํŠธ ์ข…์† ์ƒ˜ํ”Œ์€ ๋ถ„๋ฆฌ +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ƒ˜ํ”Œ ๋ฐ์ดํ„ฐ๋Š” `src/data` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- ํ”„๋กœ์ ํŠธ ์ „์šฉ ๋Œ€์‹œ๋ณด๋“œ ์ƒ˜ํ”Œ์€ `src/features/dashboard`์— ๋‘  + +## 8. ๋ฐฐํฌ ๋ฉ”๋ชจ + +- Nexus publish ๋Œ€์ƒ registry๋Š” `package.json`์˜ `publishConfig.registry` +- alpha ๋ฒ„์ „ ๋ฐฐํฌ๋Š” `npm publish --tag alpha` +- Nexus ์ธ์ฆ์€ `~/.npmrc`์˜ `username / _password(base64) / email` ๋ฐฉ์‹์œผ๋กœ ํ™•์ธ + +## 9. etc ์šด์˜ ๊ธฐ์ค€ + +- ๋ถ€๊ฐ€ ์„œ๋ฒ„/DB/ํƒ€์–ธ์–ด ํ”„๋กœ์ ํŠธ๋Š” `etc` ์•„๋ž˜์—์„œ ๋ถ„๋ฆฌ ๊ด€๋ฆฌ +- ์„œ๋ฒ„ ์˜ˆ์‹œ: `etc/servers/work-server` +- DB ์˜ˆ์‹œ: `etc/db/work-db` +- `etc` ๋‚ด๋ถ€ ๋น„๋ฐ€๊ฐ’๊ณผ ์ƒ์„ฑ๋ฌผ์€ ์ปค๋ฐ‹ ์ œ์™ธ + - `.env` + - `node_modules` + - `dist` + - `*.log` + +## 10. Plan ๊ธฐ๋Šฅ ๋ฌธ์„œ ๋ฉ”๋ชจ + +- `Plan` ๊ธฐ๋Šฅ์€ `src/features/planBoard`์—์„œ ๊ด€๋ฆฌ +- ํ˜„์žฌ ์•ฑ์—๋Š” ๋ชฉ๋ก/์ƒ์„ธ ๋ณด๋“œ, release ๊ฒ€์ˆ˜, ์ฐจํŠธ, ์Šค์ผ€์ค„ ํ™”๋ฉด์ด ํ•จ๊ป˜ ํฌํ•จ๋จ +- ๊ธฐ๋ณธ ์ƒํƒœ๋Š” `๋“ฑ๋ก`, `์ž‘์—…์ค‘`, `์ž‘์—…์™„๋ฃŒ`, `๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ`, `์™„๋ฃŒ` +- ์ž๋™ํ™” ์ง„ํ–‰์€ `workerStatus`๋กœ ๋ณ„๋„ ๊ด€๋ฆฌํ•˜๋ฉฐ ๋ธŒ๋žœ์น˜ ์ค€๋น„, ์ž๋™ ์ž‘์—…, release/main ๋ฐ˜์˜ ์ƒํƒœ๋ฅผ ํ‘œํ˜„ +- `์ž‘์—…์‹œ์ž‘` ์ดํ›„์—๋Š” ์›๋ณธ ์š”์ฒญ(`์ž‘์—… ID`, ์›๋ณธ ๋ฉ”๋ชจ`)์„ ์ˆ˜์ •ํ•˜์ง€ ์•Š๊ณ  ์กฐ์น˜ ์ด๋ ฅ์œผ๋กœ ๋ˆ„์  ๊ธฐ๋ก +- ๊ถŒํ•œ ํ† ํฐ์ด ์—†์œผ๋ฉด ์กฐํšŒ ์ค‘์‹ฌ์œผ๋กœ ๋™์ž‘ํ•˜๋ฉฐ ๋ฏผ๊ฐ ๋ฉ”๋ชจ์™€ ์†Œ์Šค ์ž‘์—… ์ผ๋ถ€๋Š” ์ œํ•œ ๋˜๋Š” ๋งˆ์Šคํ‚น +- ๊ด€๋ จ ๊ธฐ๋Šฅ ๋ฌธ์„œ๋Š” `docs/features/plan-board-review.md`, `plan-automation.md`, `plan-schedule.md`, `plan-usage.md` ์ฐธ๊ณ  diff --git a/docs/chat-frontend-rewrite-plan.md b/docs/chat-frontend-rewrite-plan.md new file mode 100644 index 0000000..8a1a979 --- /dev/null +++ b/docs/chat-frontend-rewrite-plan.md @@ -0,0 +1,249 @@ +# Chat Frontend Rewrite Plan + +## Goal + +Rebuild the chat frontend around explicit feature boundaries instead of one large panel. The rewrite keeps the current server contract for now, but removes direct API coupling from UI components and preserves the current mobile visual structure. + +## Non-Goals + +The first rewrite wave does not change: + +1. Work server REST endpoint shapes +2. Work server WebSocket event payload shapes +3. Existing chat database schema +4. Current mobile interaction model and visual hierarchy + +## Current Pain Points + +The existing frontend mixes too many concerns in the same render tree: + +1. Session routing and panel selection +2. Conversation list fetch and sort +3. Conversation detail recovery +4. Composer draft and upload lifecycle +5. WebSocket connection and reconnect +6. Runtime dashboard fetch and live updates +7. Error log loading +8. Notification unread sync +9. Visibility, focus, reconnect, and page restore sync + +This creates three classes of failures: + +1. Render loops from unstable effect dependencies +2. Request storms from duplicate fetch paths +3. Partial outages where one failing concern blanks the whole chat workspace + +## Rewrite Strategy + +The rewrite is frontend-first, but not frontend-only in architecture. The new frontend must assume that API latency and WebSocket reconnects can fail, and each feature controller must degrade independently. + +### Guiding Rules + +1. UI components do not call REST helpers directly +2. UI components do not build WebSocket URLs directly +3. One feature controller owns one feature's network lifecycle +4. Shell state never performs data fetches +5. Mobile layout is preserved while data flow is replaced under it + +## Feature Inventory + +### 1. Workspace Shell + +Responsibilities: + +1. Hold `chat | runtime | errors` selection +2. Hold active session id +3. Hold mobile split-pane visibility +4. Compose feature panes + +Rules: + +1. No direct REST calls +2. No direct socket usage +3. No data caching + +### 2. Conversation List + +Responsibilities: + +1. Load room summaries +2. Search and sort locally +3. Create, rename, delete, and select rooms +4. Expose unread and processing badges + +Rules: + +1. One fetch source +2. One short-lived in-flight dedupe layer +3. No room detail fetch inside the list controller + +### 3. Conversation Room + +Responsibilities: + +1. Load one room detail +2. Merge server messages with optimistic local state +3. Recover state after reconnect +4. Mark replies as read + +Rules: + +1. Only one active detail request at a time +2. Detail loading must never fan out into list reloads +3. Loading and recovery state must be local to the active room + +### 4. Composer + +Responsibilities: + +1. Hold draft and attachments +2. Submit queue or direct requests +3. Retry, cancel, and delete pending items +4. Manage optimistic user/request messages + +Rules: + +1. No list-wide refresh on send +2. No runtime refresh coupled to draft input + +### 5. Live Connection + +Responsibilities: + +1. Open and maintain the shared chat socket +2. Route message, job, runtime, and activity events +3. Reconnect with bounded recovery +4. Publish connection state through a shared adapter + +Rules: + +1. No duplicate context writes +2. Background transitions are throttled +3. Reconnect only restores the active room by default + +### 6. Runtime + +Responsibilities: + +1. Load runtime snapshot +2. Show queue and active jobs +3. Load per-job detail +4. Support remove and cancel actions + +Rules: + +1. Runtime refresh is separate from room detail refresh +2. Runtime failure must not blank the chat room UI + +### 7. Error Viewer + +Responsibilities: + +1. Load error log list +2. Render error detail and resource previews + +Rules: + +1. Fully isolated from chat room state + +### 8. Notification Integration + +Responsibilities: + +1. Unread badges +2. Notification center list/detail +3. Offline room notifications + +Rules: + +1. No room detail polling from notification badge refresh +2. No direct dependency from notification UI to chat room rendering + +## New Frontend Layers + +### A. UI Layer + +Files under `components/` and pane files. + +Responsibilities: + +1. Render props only +2. Emit user actions only + +### B. Feature Controller Layer + +Files under `hooks/`. + +Responsibilities: + +1. Manage one feature's state machine +2. Translate UI actions to gateway calls +3. Own loading and error state + +### C. Gateway Layer + +Files under `data/`. + +Responsibilities: + +1. Wrap all chat REST calls +2. Wrap all chat socket entry points +3. Normalize fallback behavior and timeouts in one place + +This is the critical separation that the old frontend does not have. + +## Target Folder Shape + +```text +src/app/main/chatV2/ + ChatWorkspaceV2.tsx + types.ts + data/ + chatGateway.ts + chatConnectionGateway.ts + hooks/ + useChatWorkspaceState.ts + useConversationListController.ts + useConversationRoomController.ts + useComposerController.ts + useRuntimeController.ts + useNotificationController.ts + components/ + ConversationListPane.tsx + ConversationRoomPane.tsx + Composer.tsx + RuntimePane.tsx + ErrorPane.tsx +``` + +## Migration Waves + +### Wave 1 + +1. Freeze mobile layout +2. Introduce chatV2 gateway layer +3. Move list/detail/runtime access behind the gateway + +### Wave 2 + +1. Replace list controller +2. Replace room controller +3. Replace composer controller + +### Wave 3 + +1. Reconnect runtime and notifications through new controllers +2. Remove old `MainChatPanel` effect chains + +### Wave 4 + +1. Make `MainChatPanel` a thin compatibility wrapper or replace it entirely + +## Success Criteria + +1. Main load triggers one list fetch +2. Opening one room triggers one detail fetch +3. No direct browser fallback to external `:3100` ports on remote hosts +4. WebSocket and REST routing live in one gateway boundary +5. One pane can fail without blanking the others +6. Mobile layout matches the pre-rewrite visual structure diff --git a/docs/components/check-combo.md b/docs/components/check-combo.md new file mode 100755 index 0000000..775f83c --- /dev/null +++ b/docs/components/check-combo.md @@ -0,0 +1,34 @@ +# Check Combo Input + +## ๋ชฉ์  + +`code/value` ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ ์—ฌ๋Ÿฌ ํ•ญ๋ชฉ์„ ์ฒดํฌ๋ฐ•์Šค ํ˜•ํƒœ๋กœ ์„ ํƒํ•˜๊ณ , ์‹ค์ œ ๊ฐ’์€ `code[]`๋กœ ์œ ์ง€ํ•˜๋Š” ๋‹ค์ค‘ ์„ ํƒ combo ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/inputs/checkCombo +โ”œโ”€ CheckComboUI.tsx +โ”œโ”€ index.ts +โ”œโ”€ plugins/ +โ”œโ”€ samples/ +โ””โ”€ types/ +``` + +## ์ฃผ์š” props + +- `data: { code, value }[]` +- `value`, `defaultValue` +- `onChange(codes, items)` +- `showSearch` +- `allowClear` +- `placeholder` + +## plugins + +- `createCheckComboPlaceholderPlugin` +- `createCheckComboSortPlugin` + +## ์ƒ˜ํ”Œ + +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ: `src/components/inputs/checkCombo/samples/Sample.tsx` diff --git a/docs/components/codex-diff-previewer.md b/docs/components/codex-diff-previewer.md new file mode 100755 index 0000000..33e1abc --- /dev/null +++ b/docs/components/codex-diff-previewer.md @@ -0,0 +1,35 @@ +# Codex Diff Previewer + +## ๋ชฉ์  + +๋ณ€๊ฒฝ ํŒŒ์ผ์˜ ์ „์ฒด ์†Œ์Šค์™€ raw diff๋ฅผ codex ์Šคํƒ€์ผ ์•„์ฝ”๋””์–ธ์œผ๋กœ ํ•จ๊ป˜ ๋ณด์—ฌ์ฃผ๋Š” ๊ณตํ†ต preview ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/previewer +โ”œโ”€ CodexDiffBlock.tsx +โ”œโ”€ CodexDiffPreviewer.tsx +โ”œโ”€ CodexDiffPreviewer.css +โ”œโ”€ samples/ +โ”‚ โ””โ”€ CodexDiffSample.tsx +โ””โ”€ index.ts +``` + +## ์ฃผ์š” props + +- `files` +- `diffText` +- `title` +- `description` +- `height` + +## ๊ณตํ†ต ์‚ฌ์šฉ์ฒ˜ + +- ์ž‘์—…์ผ์ง€ `## ์†Œ์Šค` ํƒญ +- ์ผ๋ฐ˜ ๋ฌธ์„œ์˜ ````diff```` ์ฝ”๋“œ ๋ธ”๋ก +- previewer ์ƒ˜ํ”Œ ๊ฐค๋Ÿฌ๋ฆฌ + +## ์ƒ˜ํ”Œ + +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ: `src/components/previewer/samples/CodexDiffSample.tsx` diff --git a/docs/components/component-addition-suggestions.md b/docs/components/component-addition-suggestions.md new file mode 100755 index 0000000..3620fe2 --- /dev/null +++ b/docs/components/component-addition-suggestions.md @@ -0,0 +1,130 @@ +# ์‹ ๊ทœ ์ปดํฌ๋„ŒํŠธ ํ›„๋ณด 2์ฐจ ์ •๋ฆฌ + +## ์‹ ๊ทœ ์ปดํฌ๋„ŒํŠธ ํ›„๋ณด 7์ฐจ ์ œ์•ˆ + +### ๋ชฉ์  + +ํ˜„์žฌ `release` ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€์œผ๋กœ ๊ธฐ์กด ์ปดํฌ๋„ŒํŠธ์™€ ๊ฒน์น˜์ง€ ์•Š๋Š” ์‹ ๊ทœ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ํ›„๋ณด๋ฅผ ์ œ์•ˆํ•ฉ๋‹ˆ๋‹ค. + +์ด ๊ธ€์€ ๊ฒ€ํ† ์šฉ plan ๊ฒŒ์‹œํŒ ์ž‘์„ฑ๋งŒ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, ์ž๋™ํ™” ์ ‘์ˆ˜๋Š” ํ•˜์ง€ ์•Š๊ณ  ๋ฏธ์ ‘์ˆ˜ ์ƒํƒœ๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. + +### release ๊ธฐ์ค€ ํ™•์ธ + +- ์ด๋ฏธ ์กด์žฌ: Dashboard Report Card, Progress/MultiProgress, Search Command, Popup/Select/CheckCombo ์ž…๋ ฅ, Markdown Preview, Previewer/Codex Diff, Status Badge, Window, DataListTable, EmbeddedMap, TextMemo/GPS/API ์ƒ˜ํ”Œ ์œ„์ ฏ +- ์ œ์•ˆ ๋ฐฉํ–ฅ: Plan/Board/History ํ™”๋ฉด์—์„œ ๋ฐ˜๋ณต๋  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์ง€๋งŒ ์•„์ง ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌ๋˜์ง€ ์•Š์€ ์กฐํ•ฉํ˜• UI + +### ์‹ ๊ทœ ํ›„๋ณด + +#### 1. Query Filter Builder UI + +๋ณต์ˆ˜ ์กฐ๊ฑด ํ•„ํ„ฐ๋ฅผ ํ–‰ ๋‹จ์œ„๋กœ ์ถ”๊ฐ€ํ•˜๊ณ  ์ €์žฅํ•  ์ˆ˜ ์žˆ๋Š” ํ•„ํ„ฐ ๋นŒ๋” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan Board ๊ณ ๊ธ‰ ํ•„ํ„ฐ, History ๊ฒ€์ƒ‰, Board ๊ฒ€์ƒ‰ +- ์ฃผ์š” props: `fields`, `operators`, `value`, `onChange`, `presets`, `compact` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ํ™”๋ฉด๋งˆ๋‹ค ํฉ์–ด์งˆ ์ˆ˜ ์žˆ๋Š” ํ•„ํ„ฐ ์กฐ๊ฑด UI๋ฅผ ์ผ๊ด€๋œ ํŒจํ„ด์œผ๋กœ ์ •๋ฆฌ + +#### 2. Timeline Activity Feed UI + +์ž‘์—… ์ƒํƒœ ๋ณ€๊ฒฝ, ์ ‘์ˆ˜, release/main ๋ฐ˜์˜, ์˜ค๋ฅ˜ ์ด๋ฒคํŠธ๋ฅผ ์‹œ๊ฐ„์ˆœ์œผ๋กœ ๋ณด์—ฌ์ฃผ๋Š” ํ™œ๋™ ํ”ผ๋“œ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan ์ƒ์„ธ, History ์ƒ์„ธ, ์ž๋™ํ™” ์‹คํ–‰ ์ด๋ ฅ +- ์ฃผ์š” props: `items`, `groupByDate`, `statusResolver`, `dense`, `renderMeta` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ๋กœ๊ทธ์„ฑ ํ…์ŠคํŠธ๋ฅผ ์ถ”์  ๊ฐ€๋Šฅํ•œ UI๋กœ ์ „ํ™˜ํ•˜๊ณ  ์ตœ๊ทผ ๋ณ€๊ฒฝ ๋งฅ๋ฝ์„ ๋น ๋ฅด๊ฒŒ ํŒŒ์•… + +#### 3. Evidence Attachment Strip UI + +์Šคํฌ๋ฆฐ์ƒท, diff, ๋กœ๊ทธ, ๋งํฌ ๊ฐ™์€ ์ฆ๋น™ ์ž๋ฃŒ๋ฅผ ํ•œ ์ค„ ์นด๋“œ ๋ชฉ๋ก์œผ๋กœ ๋…ธ์ถœํ•˜๋Š” ์ฒจ๋ถ€ ์ŠคํŠธ๋ฆฝ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan ๊ฒ€์ฆ ์ฆ๋น™, Preview ๊ฒฐ๊ณผ, History ์ƒ์„ธ +- ์ฃผ์š” props: `attachments`, `onPreview`, `onDownload`, `maxVisible`, `variant` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ์ฆ๋น™ ์ž๋ฃŒ ํ‘œ์‹œ์™€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง„์ž…์ ์„ ๊ณตํ†ตํ™” + +### ์šฐ์„ ์ˆœ์œ„ ์ œ์•ˆ + +1. Query Filter Builder UI +2. Timeline Activity Feed UI +3. Evidence Attachment Strip UI + +์šฐ์„  1๋ฒˆ์„ ๋จผ์ € ๊ฒ€ํ† ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. Plan Board์™€ History์—์„œ ํ•„ํ„ฐ ์กฐ๊ฑด์ด ๊ณ„์† ๋Š˜์–ด๋‚  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„ ์žฌ์‚ฌ์šฉ ํšจ๊ณผ๊ฐ€ ๊ฐ€์žฅ ํฝ๋‹ˆ๋‹ค. + +## ๋ชฉ์  + +๊ธฐ์กด์— ๊ฐœ๋ฐœ ์™„๋ฃŒ๋œ `FormField`, `StateKit`, `DataListTable`๊ณผ ์ด๋ฏธ ๊ฐœ๋ฐœ ์ ‘์ˆ˜๋œ `Action Toolbar UI`, `Detail Inspector Panel`, `Timeline / Activity Log UI`, `Confirm Dialog UI`, `Notification Toast / Action Feedback UI`, `Date Range Input`, `File Attachment List`, `Component Usage Doc Card`, `Split Pane Layout`์€ ์ด๋ฒˆ ํ›„๋ณด์—์„œ ์ œ์™ธํ•ฉ๋‹ˆ๋‹ค. + +์ด๋ฒˆ ๋ฌธ์„œ๋Š” ํ˜„์žฌ ์ฝ”๋“œ๋ฒ ์ด์Šค์™€ ๊ธฐ์กด Board/Plan ์ ‘์ˆ˜ ์ด๋ ฅ์— ์—†๋Š” ์‹ ๊ทœ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋งŒ ๋‹ค์‹œ ์ถ”๋ ค ์ดํ›„ Plan ํ›„์† ์ž‘์—… ํ›„๋ณด๋ฅผ ๋งŒ๋“œ๋Š” ๋ชฉ์ ์ž…๋‹ˆ๋‹ค. + +## ์ œ์™ธ ๊ธฐ์ค€ + +- ์ด๋ฏธ ๊ตฌํ˜„ ์™„๋ฃŒ๋œ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋Š” ์ค‘๋ณต ํ›„๋ณด๋กœ ๋‹ค์‹œ ์˜ฌ๋ฆฌ์ง€ ์•Š์Œ +- ์ด๋ฏธ Board/Plan์—์„œ ๊ฐœ๋ฐœ ์ ‘์ˆ˜๋œ ์ปดํฌ๋„ŒํŠธ๋Š” ์‹ ๊ทœ ํ›„๋ณด์—์„œ ์ œ์™ธ +- ์•ฑ ์ „์šฉ ํ™”๋ฉด ์กฐํ•ฉ๋ณด๋‹ค ์—ฌ๋Ÿฌ ๊ธฐ๋Šฅ์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ณตํ†ต UI๋ฅผ ์šฐ์„  ์„ ์ • + +## ์‹ ๊ทœ ํ›„๋ณด + +### 1. Drawer / Side Sheet UI + +๋ณธ๋ฌธ ํ๋ฆ„์„ ๋Š์ง€ ์•Š๊ณ  ์šฐ์ธก ๋˜๋Š” ํ•˜๋‹จ์—์„œ ๋ณด์กฐ ํŽธ์ง‘ ํ™”๋ฉด์„ ์—ฌ๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan ์ƒ์„ธ ๋ณด์กฐ ํŽธ์ง‘, ์„ค์ • ํ™”๋ฉด, ๋ชจ๋ฐ”์ผ ์ƒ์„ธ ํŒจ๋„ +- ์ฃผ์š” props: `open`, `placement`, `width`, `title`, `footer`, `onClose` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ์ „์ฒด ํ™”๋ฉด ์ „ํ™˜ ์—†์ด ๋ณด์กฐ ์ž‘์—…์„ ์—ด๊ณ  ๋‹ซ๋Š” ํŒจํ„ด์„ ๊ณตํ†ตํ™” + +### 2. Description List / Key Value Summary UI + +์ƒ์„ธ ์ •๋ณด ํ™”๋ฉด์—์„œ ๋ผ๋ฒจ๊ณผ ๊ฐ’์„ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์ •๋ฆฌํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan ๋ฉ”ํƒ€ ์ •๋ณด, ๋ฐฉ๋ฌธ ์ด๋ ฅ ์ƒ์„ธ, ์•ฑ ์„ค์ • ์š”์•ฝ +- ์ฃผ์š” props: `items`, `columns`, `size`, `labelWidth`, `copyable` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ์ƒ์„ธ ํ™”๋ฉด๋งˆ๋‹ค ๋ฐ˜๋ณต๋˜๋Š” ๋ฉ”ํƒ€ ์ •๋ณด ๋ ˆ์ด์•„์›ƒ์„ ์ค„์ž„ + +### 3. Stepper / Process Flow UI + +๋“ฑ๋ก, ์ž‘์—…์ค‘, `release` ๋ฐ˜์˜, `main` ๋ฐ˜์˜ ๊ฐ™์€ ๋‹จ๊ณ„๋ฅผ ์ˆœ์„œํ˜•์œผ๋กœ ๋ณด์—ฌ์ฃผ๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Plan ์ƒํƒœ ํ๋ฆ„, ๋ฐฐํฌ ์ง„ํ–‰ ํ‘œ์‹œ, ์ž๋™ํ™” ๋‹จ๊ณ„ ์š”์•ฝ +- ์ฃผ์š” props: `steps`, `current`, `status`, `direction`, `compact` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ํ…์ŠคํŠธ ์ƒํƒœ ๋‚˜์—ด๋ณด๋‹ค ํ˜„์žฌ ๋‹จ๊ณ„์™€ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ์ง๊ด€์ ์œผ๋กœ ์ „๋‹ฌ + +### 4. Tag Input UI + +์—ฌ๋Ÿฌ ํ‚ค์›Œ๋“œ๋‚˜ ๋ผ๋ฒจ์„ ์ง์ ‘ ์ถ”๊ฐ€ํ•˜๊ณ  ์‚ญ์ œํ•˜๋Š” ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Board ํƒœ๊ทธ, ๊ฒ€์ƒ‰ ์กฐ๊ฑด ์ €์žฅ, ์ฆ์  ๋ถ„๋ฅ˜, ๋น ๋ฅธ ํ•„ํ„ฐ ์กฐํ•ฉ +- ์ฃผ์š” props: `value`, `suggestions`, `maxTags`, `allowCustom`, `onChange` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ๋‹ค์ค‘ ์กฐ๊ฑด ์ž…๋ ฅ์„ `select`์™€ ๋ณ„๋„๋กœ ๋‹ค๋ค„ ๋ฐ˜๋ณต ํ•„ํ„ฐ ๊ตฌ์„ฑ์ด ์‰ฌ์›Œ์ง + +### 5. Breadcrumb / Context Path UI + +ํ˜„์žฌ ์œ„์น˜์™€ ์ƒ์œ„ ๊ฒฝ๋กœ๋ฅผ ์งง๊ฒŒ ๋ณด์—ฌ์ฃผ๋Š” ํƒ์ƒ‰ ๋ณด์กฐ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: Docs ์ƒ์„ธ, Components ์ƒ˜ํ”Œ ์ƒ์„ธ, History ์ƒ์„ธ ์ง„์ž… ๊ฒฝ๋กœ +- ์ฃผ์š” props: `items`, `separator`, `compact`, `onNavigate` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ๊นŠ์€ ๋ฉ”๋‰ด ๊ตฌ์กฐ์—์„œ ํ˜„์žฌ ์œ„์น˜ ํŒŒ์•…๊ณผ ์ƒ์œ„ ์ด๋™ ๋น„์šฉ์„ ๋‚ฎ์ถค + +### 6. Property Grid UI + +์„ค์ •๊ฐ’์ด๋‚˜ ์˜ต์…˜ ๋ชฉ๋ก์„ 2์—ด ๋˜๋Š” ๋‹ค์—ด๋กœ ๋ฐฐ์น˜ํ•ด ๋น ๋ฅด๊ฒŒ ํŽธ์ง‘ํ•˜๋Š” ์„ค์ •ํ˜• ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +- ์ ์šฉ ์œ„์น˜: ์•ฑ ์„ค์ •, ์ž๋™ํ™” ์„ค์ •, ์œ„์ ฏ ์˜ต์…˜ ํŽธ์ง‘ +- ์ฃผ์š” props: `sections`, `fields`, `columns`, `readonly`, `onChange` +- ๊ธฐ๋Œ€ ํšจ๊ณผ: ์„ค์ • ํผ์„ ๊ธด ์„ธ๋กœ ๋‚˜์—ด ๋Œ€์‹  ๋ฐ€๋„ ์žˆ๊ฒŒ ๊ตฌ์„ฑ ๊ฐ€๋Šฅ + +## ๊ถŒ์žฅ ์ง„ํ–‰ ์ˆœ์„œ + +1. `Description List / Key Value Summary UI` +2. `Stepper / Process Flow UI` +3. `Drawer / Side Sheet UI` +4. `Tag Input UI` +5. `Property Grid UI` +6. `Breadcrumb / Context Path UI` + +## ๊ฒ€์ฆ ๊ธฐ์ค€ + +- ๋ชจ๋ฐ”์ผ ํญ์—์„œ `drawer`, `stepper`, `property grid`๊ฐ€ ๊ฐ€๋กœ ๋„˜์นจ ์—†์ด ๋™์ž‘ํ•˜๋Š”์ง€ ํ™•์ธ +- Plan ์ƒ์„ธ์™€ ์„ค์ • ํ™”๋ฉด์— ๋ถ™์˜€์„ ๋•Œ ๊ธฐ์กด `antd` ๊ธฐ๋ณธ ์ปดํฌ๋„ŒํŠธ ์กฐํ•ฉ๋ณด๋‹ค ๋ฐ˜๋ณต ์ฝ”๋“œ๊ฐ€ ์ค„์–ด๋“œ๋Š”์ง€ ํ™•์ธ +- ์ฝ๊ธฐ ์ „์šฉ ํ™”๋ฉด๊ณผ ํŽธ์ง‘ ํ™”๋ฉด์—์„œ ๊ฐ™์€ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ฌด๋ฆฌ ์—†์ด ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š”์ง€ ํ™•์ธ + +## ๋ฉ”๋ชจ + +- ๋‹ค์Œ ํ›„๋ณด ๊ตฌํ˜„ ์‹œ์—๋Š” `samples/BaseSample.tsx`, `samples/Sample.tsx`, ํ•„์š” ์‹œ `plugins/*.plugin.ts`๋ฅผ ๊ฐ™์€ ๋ฌถ์Œ์œผ๋กœ ์ค€๋น„ +- Docs ๋ฌธ์„œ์—๋Š” ๋ชฉ์ , ์ฃผ์š” props, ์ ์šฉ ์œ„์น˜, ํ™•์žฅ ํฌ์ธํŠธ๋ฅผ ํ•จ๊ป˜ ๊ธฐ๋ก diff --git a/docs/components/evidence-attachment-strip-ui.md b/docs/components/evidence-attachment-strip-ui.md new file mode 100755 index 0000000..f514db9 --- /dev/null +++ b/docs/components/evidence-attachment-strip-ui.md @@ -0,0 +1,46 @@ +# Evidence Attachment Strip UI + +## ๋ชฉ์  + +Plan/Board ๊ณ„์—ด ํ™”๋ฉด์—์„œ ๋ฐ˜๋ณต๋˜๋Š” ์‚ฐ์ถœ๋ฌผ ์นด๋“œ, ๋งํฌ, ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง„์ž… UI๋ฅผ ๊ณตํ†ต ์ŠคํŠธ๋ฆฝ์œผ๋กœ ์ •๋ฆฌํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ์ง€์› ํƒ€์ž… + +- `image` +- `markdown` +- `code` +- `text` +- `json` +- `preview` +- `video` +- `audio` +- `pdf` +- `empty` + +## ์ฃผ์š” props + +- `attachments` +- `onPreview` +- `onCopy` +- `maxVisible` +- `compact` +- `emptyText` +- `title` +- `description` + +## ๊ธฐ๋ณธ ์•ก์…˜ + +- ๋งํฌ ์—ด๊ธฐ +- ๋ณต์‚ฌ +- ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ง„์ž… + +## ์ ์šฉ ์˜ˆ์‹œ + +- `PlanBoardPage`์˜ `WorklogEvidenceTab` ์‚ฐ์ถœ๋ฌผ Preview ์˜์—ญ +- ์ž‘์—…์ผ์ง€/์Šคํฌ๋ฆฐ์ƒท/๋กœ๊ทธ/preview ๋งํฌ ํ˜ผํ•ฉ ์นด๋“œ ๋ชฉ๋ก +- ๋ชจ๋ฐ”์ผ ๋ณด์กฐ ํŒจ๋„ ๋˜๋Š” ์ƒ์„ธ ๋ชจ๋‹ฌ์˜ compact ์ฒจ๋ถ€ ๋ชฉ๋ก + +## ํ™•์žฅ ํฌ์ธํŠธ + +- `EvidenceAttachmentPreviewBody`๋ฅผ ๋ณ„๋„ export ํ•˜๋ฏ€๋กœ ์ƒ์„ธ ๋ชจ๋‹ฌ ๋ณธ๋ฌธ์—์„œ ๊ฐ™์€ ๋ Œ๋”๋Ÿฌ๋ฅผ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- `copyValue`, `language`, `format`์„ ํ•ญ๋ชฉ๋ณ„๋กœ ์ œ์–ดํ•ด ์ฝ”๋“œ/ํ…์ŠคํŠธ/๊ฒฝ๋กœํ˜• ์‚ฐ์ถœ๋ฌผ ํ‘œํ˜„์„ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. diff --git a/docs/components/input.md b/docs/components/input.md new file mode 100755 index 0000000..24073ce --- /dev/null +++ b/docs/components/input.md @@ -0,0 +1,66 @@ +# Input + +## ๋ชฉ์  + +Ant Design `Input`์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ•˜๋˜, ํƒ€์ดํ•‘ ์ค‘์—๋Š” ๋‚ด๋ถ€ ์ƒํƒœ๋งŒ ๋ณ€๊ฒฝํ•˜๊ณ  `Enter` ๋˜๋Š” `blur` ์‹œ์ ์—๋งŒ ์™ธ๋ถ€ `onChange`๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/inputs/primitives/input +โ”œโ”€ plugins/ +โ”‚ โ”œโ”€ index.ts +โ”‚ โ””โ”€ input.plugin.ts +โ”œโ”€ samples/ +โ”‚ โ”œโ”€ Sample.tsx +โ”‚ โ””โ”€ ValidInputSample.tsx +โ”œโ”€ types/ +โ”‚ โ”œโ”€ index.ts +โ”‚ โ””โ”€ input.ts +โ”œโ”€ InputUI.tsx +โ””โ”€ index.ts +``` + +## ๋™์ž‘ ๋ฐฉ์‹ + +- ์ž…๋ ฅ ์ค‘์—๋Š” ๋‚ด๋ถ€ `draftValue`๋งŒ ๋ณ€๊ฒฝ +- `Enter` ์‹œ ์™ธ๋ถ€ `onChange` ํ˜ธ์ถœ +- `blur` ์‹œ ์™ธ๋ถ€ `onChange` ํ˜ธ์ถœ +- ๊ฒ€์ฆ์ด๋‚˜ ์ถ”๊ฐ€ ๊ธฐ๋Šฅ์€ `commitPlugins`๋กœ ์ฃผ์ž… +- ๋‚˜๋จธ์ง€ props๋Š” Ant Design `InputProps`๋ฅผ ๊ทธ๋Œ€๋กœ ์ „๋‹ฌ + +## ์ƒ˜ํ”Œ ๊ทœ์น™ + +- `samples/Sample.tsx`: ๊ธฐ๋ณธํ˜• `InputUI` +- `samples/ValidInputSample.tsx`: validation plugin์„ ์ ์šฉํ•œ ํ™•์žฅ ์ƒ˜ํ”Œ + +## ์‚ฌ์šฉ ์˜ˆ์‹œ + +```tsx + { + setValue(event.target.value); + }} +/> +``` + +```tsx + nextValue.trim().length >= 3), + ]} + onChange={(event) => { + setValue(event.target.value); + }} +/> +``` + +## ์ฐธ๊ณ  + +- ์™ธ๋ถ€์—์„œ ์ œ์–ดํ•˜๋Š” ๊ฐ’์€ ํ™•์ • ์‹œ์ ์—๋งŒ ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค. +- `InputUI.tsx` ํ•˜๋‚˜๋งŒ ๋‘๊ณ  ๊ธฐ๋Šฅ์€ plugin์œผ๋กœ ํ™•์žฅํ•˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค. +- API ๊ฒŒ์‹œํŒ์ด๋‚˜ ๋ฌธ์„œ ์˜ˆ์‹œ๋Š” `samples/Sample.tsx`๋ฅผ ๋Œ€ํ‘œ ์ƒ˜ํ”Œ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. diff --git a/docs/components/popup.md b/docs/components/popup.md new file mode 100755 index 0000000..6838cb1 --- /dev/null +++ b/docs/components/popup.md @@ -0,0 +1,35 @@ +# Popup Input + +## ๋ชฉ์  + +`[input][button][readonly input]` ํ˜•ํƒœ๋กœ ๊ฒ€์ƒ‰์–ด ์ž…๋ ฅ, ๋ฒ„ํŠผ ์•ก์…˜, ์„ ํƒ ๊ฒฐ๊ณผ ํ‘œ์‹œ๋ฅผ ํ•œ ์ค„์—์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/inputs/popup +โ”œโ”€ PopupUI.tsx +โ”œโ”€ index.ts +โ”œโ”€ plugins/ +โ”œโ”€ samples/ +โ””โ”€ types/ +``` + +## ์ฃผ์š” props + +- `value`, `defaultValue` +- `resultValue` +- `onChange` +- `onButtonClick` +- `buttonText` +- `inputPlaceholder` +- `resultPlaceholder` + +## plugins + +- `createPopupButtonTextPlugin` +- `createPopupResultPlaceholderPlugin` + +## ์ƒ˜ํ”Œ + +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ: `src/components/inputs/popup/samples/Sample.tsx` diff --git a/docs/components/previewer-ui.md b/docs/components/previewer-ui.md new file mode 100755 index 0000000..c42f018 --- /dev/null +++ b/docs/components/previewer-ui.md @@ -0,0 +1,30 @@ +# Previewer UI + +## ๋ชฉ์  + +๋‹ค์–‘ํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ณตํ†ต ์นด๋“œ ํ˜•ํƒœ๋กœ ๋ฏธ๋ฆฌ๋ณด๊ธฐํ•  ์ˆ˜ ์žˆ๋Š” previewer ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ์ง€์› ํƒ€์ž… + +- `text` +- `json` +- `code` +- `image` +- `markdown` +- `empty` + +## ์ถ”๊ฐ€๋กœ ์œ ์šฉํ•œ preview ํƒ€์ž… + +- `markdown`: ์ž‘์—…์ผ์ง€, ๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +- `empty`: ํŒŒ์ผ ๋ฏธ์„ ํƒ, ๋ฐ์ดํ„ฐ ์—†์Œ ์ƒํƒœ + +## ์ฃผ์š” props + +- `type` +- `title` +- `description` +- `value` +- `language` +- `imageAlt` +- `height` +- `toolbar` diff --git a/docs/components/process-flow-ui.md b/docs/components/process-flow-ui.md new file mode 100755 index 0000000..0d92cad --- /dev/null +++ b/docs/components/process-flow-ui.md @@ -0,0 +1,108 @@ +# Process Flow UI + +## ๋ชฉ์  + +Plan, Board, History ํ™”๋ฉด์—์„œ ๊ณตํ†ต์œผ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๋‹จ๊ณ„ํ˜• ์ง„ํ–‰ ํ‘œ์‹œ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. +ํ˜„์žฌ ๋‹จ๊ณ„, ์™„๋ฃŒ ๋‹จ๊ณ„, ์‹คํŒจ ๋‹จ๊ณ„, ๋‹ค์Œ ๋Œ€๊ธฐ ๋‹จ๊ณ„๋ฅผ ํ•œ ๋ฒˆ์— ๋ณด์—ฌ์ฃผ๋ฉฐ ๊ฐ€๋กœ/์„ธ๋กœ ๋ฐฐ์น˜์™€ compact ๋ชจ๋“œ๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/processFlow +โ”œโ”€ samples/ +โ”‚ โ””โ”€ BaseSample.tsx +โ”œโ”€ types/ +โ”‚ โ”œโ”€ index.ts +โ”‚ โ””โ”€ process-flow.ts +โ”œโ”€ ProcessFlowUI.css +โ”œโ”€ ProcessFlowUI.tsx +โ””โ”€ index.ts +``` + +## ๊ธฐ๋ณธ Props + +```ts +type ProcessFlowStepStatus = 'complete' | 'current' | 'failed' | 'pending'; + +type ProcessFlowStep = { + key: string; + label: ReactNode; + description?: ReactNode; + status?: ProcessFlowStepStatus; +}; + +type ProcessFlowUIProps = { + steps: ProcessFlowStep[]; + currentStepKey?: string; + direction?: 'horizontal' | 'vertical'; + compact?: boolean; + showConnector?: boolean; + onStepClick?: (step: ProcessFlowStep, index: number) => void; + statusIcons?: Partial>; + statusStyles?: Partial>>; + statusLabels?: Partial>; +}; +``` + +## ๋™์ž‘ ๊ทœ์น™ + +- `step.status` ๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ์ƒํƒœ๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- `step.status` ๊ฐ€ ์—†์œผ๋ฉด `currentStepKey` ๊ธฐ์ค€์œผ๋กœ ์ด์ „ ๋‹จ๊ณ„๋Š” `complete`, ํ˜„์žฌ ๋‹จ๊ณ„๋Š” `current`, ์ดํ›„ ๋‹จ๊ณ„๋Š” `pending` ์œผ๋กœ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. +- `direction="horizontal"` ์€ ๋„“์€ ํ™”๋ฉด์—์„œ ๋‹จ๊ณ„ ํ๋ฆ„์„ ํ•œ ์ค„๋กœ ๋ณด์—ฌ์ฃผ๊ณ , ๋ชจ๋ฐ”์ผ ํญ์—์„œ๋Š” ์ž๋™์œผ๋กœ ์„ธ๋กœ ์Šคํƒ์œผ๋กœ ๋ฐ”๋€๋‹ˆ๋‹ค. +- `direction="vertical"` ์€ ๊ธด ์„ค๋ช…์ด๋‚˜ ์šด์˜ ์ ˆ์ฐจ์ฒ˜๋Ÿผ ํ…์ŠคํŠธ๊ฐ€ ๋งŽ์€ ํ๋ฆ„์— ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. +- `compact` ๋Š” ์นด๋“œ ์•ˆ์ชฝ, ํ…Œ์ด๋ธ” ์ƒ์„ธ, ๋ชจ๋ฐ”์ผ ์š”์•ฝ ์˜์—ญ์ฒ˜๋Ÿผ ๋ฐ€๋„๊ฐ€ ํ•„์š”ํ•œ ๊ตฌ๊ฐ„์— ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +## ๊ธฐ๋ณธ ์˜ˆ์‹œ + +```tsx +import { ProcessFlowUI } from '@/components/processFlow'; + +const steps = [ + { key: 'created', label: '๋“ฑ๋ก' }, + { key: 'working', label: '์ž‘์—…์ค‘' }, + { key: 'done', label: '์ž‘์—…์™„๋ฃŒ' }, + { key: 'released', label: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ' }, + { key: 'completed', label: '์™„๋ฃŒ' }, +]; + +export function PlanStatusFlow() { + return ; +} +``` + +## ์ƒํƒœ ํ™•์žฅ ์˜ˆ์‹œ + +```tsx +import { CheckOutlined, SyncOutlined } from '@ant-design/icons'; + +, + current: , + }} + statusStyles={{ + current: { + accent: '#7c3aed', + accentSoft: 'rgba(124, 58, 237, 0.12)', + border: 'rgba(124, 58, 237, 0.22)', + background: 'linear-gradient(180deg, rgba(245, 243, 255, 0.98) 0%, rgba(237, 233, 254, 0.84) 100%)', + text: '#4c1d95', + connector: 'rgba(124, 58, 237, 0.28)', + }, + }} +/> +``` + +## ์ ์šฉ ์˜ˆ์‹œ + +- Plan ์ƒ์„ธ: `๋“ฑ๋ก -> ์ž‘์—…์ค‘ -> ์ž‘์—…์™„๋ฃŒ -> ๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ -> ์™„๋ฃŒ` +- release/main ๋ฐ˜์˜ ๋Œ€๊ธฐ ํ๋ฆ„ ํ‘œ์‹œ +- Board ์ž๋™ํ™” ์ ‘์ˆ˜ ํ›„ ํ˜„์žฌ ์ง„ํ–‰ ๋‹จ๊ณ„ ์š”์•ฝ + +## ์žฌ์‚ฌ์šฉ ๊ฐ€์ด๋“œ + +- ๊ณตํ†ต ์šด์˜ ์ƒํƒœ๋ฅผ ์ด๋ฏธ ๋ฌธ์ž์—ด๋กœ ๋ณด์œ ํ•˜๊ณ  ์žˆ๋‹ค๋ฉด ํ™”๋ฉด ๋ ˆ์ด์–ด์—์„œ `steps` ์™€ `currentStepKey` ๋กœ๋งŒ ๋ณ€ํ™˜ํ•ด์„œ ๋ฐ”๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์ƒํƒœ ๋ผ๋ฒจ์ด ์šด์˜ ์šฉ์–ด์™€ ๋‹ค๋ฅด๋ฉด `statusLabels` ๋กœ ํ™”๋ฉด๋ณ„ ํ…์ŠคํŠธ๋งŒ ๋ฐ”๊ฟ‰๋‹ˆ๋‹ค. +- ํ”„๋กœ์ ํŠธ ๊ณ ์œ  ์ƒ‰์ด๋‚˜ ์•„์ด์ฝ˜์ด ํ•„์š”ํ•˜๋ฉด `statusStyles`, `statusIcons` ๋งŒ ๋ฎ์–ด์“ฐ๊ณ  ๊ธฐ๋ณธ ๋ ˆ์ด์•„์›ƒ์€ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. diff --git a/docs/components/search-command.md b/docs/components/search-command.md new file mode 100755 index 0000000..f8731fa --- /dev/null +++ b/docs/components/search-command.md @@ -0,0 +1,23 @@ +# Search Command + +## ๋ชฉ์  + +๋ฌธ์„œ, API, ์ปดํฌ๋„ŒํŠธ, ์œ„์ ฏ์„ ํ‚ค์›Œ๋“œ๋กœ ๋น ๋ฅด๊ฒŒ ์ฐพ๊ณ  ๋ฐ”๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ๋Š” ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ์ž…๋‹ˆ๋‹ค. + +## ํŠน์ง• + +- `AutoComplete` ๊ธฐ๋ฐ˜ ์ถ”์ฒœ ๋“œ๋กญ๋‹ค์šด +- ๋ชจ๋‹ฌ ์˜คํ”ˆ ์‹œ ์ž…๋ ฅ์ฐฝ ์ž๋™ ํฌ์ปค์Šค +- `Enter`, ํ•ญ๋ชฉ ์„ ํƒ, ๋ฐ”๊นฅ ํด๋ฆญ, `Esc`๋กœ ๋‹ซ๊ธฐ/์ด๋™ +- ๋ชจ๋ฐ”์ผ ์ƒ๋‹จ ์ œ์Šค์ฒ˜์™€ ์—ฐ๊ฒฐ ๊ฐ€๋Šฅ + +## ์ฃผ์š” props + +- `open` +- `options` +- `onClose` + +## ์ƒ˜ํ”Œ/์—ฐ๊ฒฐ + +- `src/components/search/SearchCommandModal.tsx` +- `src/layer/search/context/SearchLayerContext.tsx` diff --git a/docs/components/select.md b/docs/components/select.md new file mode 100755 index 0000000..632e18a --- /dev/null +++ b/docs/components/select.md @@ -0,0 +1,34 @@ +# Select Input + +## ๋ชฉ์  + +`code/value` ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ ์‹ค์ œ ๊ฐ’์€ `code`๋กœ ์œ ์ง€ํ•˜๊ณ , ๋“œ๋กญ๋‹ค์šด ํ‘œ์‹œ์™€ ๊ฒ€์ƒ‰์€ `value` ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋Š” select combo ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/inputs/select +โ”œโ”€ SelectUI.tsx +โ”œโ”€ index.ts +โ”œโ”€ plugins/ +โ”œโ”€ samples/ +โ””โ”€ types/ +``` + +## ์ฃผ์š” props + +- `data: { code, value }[]` +- `value`, `defaultValue` +- `onChange(code, item)` +- `showSearch` +- `allowClear` +- `placeholder` + +## plugins + +- `createSelectPlaceholderPlugin` +- `createSelectSortPlugin` + +## ์ƒ˜ํ”Œ + +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ: `src/components/inputs/select/samples/Sample.tsx` diff --git a/docs/components/status-badge.md b/docs/components/status-badge.md new file mode 100755 index 0000000..b718764 --- /dev/null +++ b/docs/components/status-badge.md @@ -0,0 +1,106 @@ +# StatusBadge + +## ๋ชฉ์  + +์ƒํƒœ ๊ฐ’์„ ๊ฐ„๋‹จํ•œ UI ๋ฐฐ์ง€๋กœ ํ‘œํ˜„ํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ํด๋” ๊ตฌ์กฐ + +```text +src/components/status-badge +โ”œโ”€ plugins/ +โ”‚ โ”œโ”€ index.ts +โ”‚ โ””โ”€ status-badge.plugin.ts +โ”œโ”€ samples/ +โ”‚ โ””โ”€ Sample.tsx +โ”œโ”€ types/ +โ”‚ โ”œโ”€ index.ts +โ”‚ โ””โ”€ status-badge.ts +โ”œโ”€ StatusBadgeUI.tsx +โ””โ”€ index.ts +``` + +## ๊ตฌ์„ฑ ์›์น™ + +- `StatusBadgeUI.tsx`: ์‹ค์ œ UI ๋ Œ๋”๋ง +- `types/`: props, plugin input ํƒ€์ž… ๊ด€๋ฆฌ +- `plugins/`: ์™ธ๋ถ€ ์ž…๋ ฅ ๋ณ€ํ™˜, props ํ›„์ฒ˜๋ฆฌ, ์ปค๋ง ํ”Œ๋Ÿฌ๊ทธ์ธ ๊ด€๋ฆฌ +- `samples/Sample.tsx`: ๋Œ€ํ‘œ ์ƒ˜ํ”Œ + +๊ณตํ†ต ํ”Œ๋Ÿฌ๊ทธ์ธ ์ œ๋„ค๋ฆญ์€ `src/types/component-plugin.ts` ์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ๊ธฐ๋ณธ Props + +```ts +type StatusBadgeProps = { + label: string; + tone?: 'success' | 'warning' | 'error' | 'processing' | 'default'; +}; +``` + +## Plugin Input ์˜ˆ์‹œ + +```ts +type StatusBadgePluginInput = { + text: string; + status?: 'ready' | 'working' | 'blocked' | 'done'; +}; +``` + +## ๊ณตํ†ต Plugin ํƒ€์ž… + +```ts +type PropsPlugin = (props: TProps) => TProps; + +type ComponentPlugin = (input: TInput) => TProps; + +type ComponentPluginFactory = + (...args: TArgs) => ComponentPlugin; +``` + +`PropsPlugin` ๋Š” ์งˆ๋ฌธํ•˜์‹  `plugin(T props) => T` ํ˜•ํƒœ๋ฅผ ์ง์ ‘ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค. +`ComponentPlugin` ๊ณผ `ComponentPluginFactory` ๋Š” ์ž…๋ ฅ ํƒ€์ž…๊ณผ UI props ํƒ€์ž…์ด ๋‹ค๋ฅด๊ฑฐ๋‚˜, ์ปค๋ง์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ๊นŒ์ง€ ํ™•์žฅํ•˜๊ธฐ ์œ„ํ•œ ํƒ€์ž…์ž…๋‹ˆ๋‹ค. + +## ์—ฌ๋Ÿฌ Plugin ํ•ฉ์„ฑ + +```ts +function plugins( + props: TProps, + pluginList: ReadonlyArray>, +): TProps +``` + +`plugins(props, Plugin[])` ํ˜•ํƒœ๋กœ ์—ฌ๋Ÿฌ props ํ”Œ๋Ÿฌ๊ทธ์ธ์„ ์ˆœ์„œ๋Œ€๋กœ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค. + +## Plugin ๊ตฌํ˜„ ์˜ˆ์‹œ + +```ts +const mapStatusBadgeInputToProps: ComponentPlugin< + StatusBadgePluginInput, + StatusBadgeProps +> = (input) => ({ + label: input.text, + tone: input.status ? statusToneMap[input.status] : 'default', +}); + +const createStatusBadgeTonePlugin: ComponentPluginFactory< + [options?: StatusBadgePluginOptions], + StatusBadgeProps +> = (options) => (props) => ({ + ...props, + tone: props.tone === 'default' ? options?.fallbackTone ?? 'default' : props.tone, +}); +``` + +## Sample ํ™œ์šฉ ๋ชฉ์  + +- API ๊ฒŒ์‹œํŒ์—์„œ ์˜ˆ์ œ UI ๋…ธ์ถœ +- ๋ฌธ์„œ ํŽ˜์ด์ง€์—์„œ ๋™์ž‘ ๋ฐฉ์‹ ์„ค๋ช… +- QA ์‹œ ์ปดํฌ๋„ŒํŠธ ๋น ๋ฅธ ํ™•์ธ + +## ํ™•์žฅ ๋ฐฉํ–ฅ + +- `map input -> props` ์™€ `props -> props` ํ”Œ๋Ÿฌ๊ทธ์ธ ์ฒด์ธ์„ ์กฐํ•ฉ +- `plugins(props, pluginList)` ๋กœ ์—ฌ๋Ÿฌ ํ›„์ฒ˜๋ฆฌ ํ”Œ๋Ÿฌ๊ทธ์ธ ์ ์šฉ +- ๊ธฐ๋ณธํ˜•์€ `PropsPlugin` ๋กœ, ํ™•์žฅํ˜•์€ `ComponentPluginFactory` ๋กœ ํ‘œ์ค€ํ™” +- ์ƒ˜ํ”Œ ์ž๋™ ์ˆ˜์ง‘ ํŽ˜์ด์ง€ ๊ตฌ์„ฑ diff --git a/docs/components/stepper.md b/docs/components/stepper.md new file mode 100755 index 0000000..8b9adf1 --- /dev/null +++ b/docs/components/stepper.md @@ -0,0 +1,43 @@ +# Stepper + +## ๋ชฉ์  + +์—ฌ๋Ÿฌ ๋‹จ๊ณ„๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ํ‘œ์‹œํ•˜๊ณ  ํ˜„์žฌ ์ง„ํ–‰ ์œ„์น˜๋ฅผ ๊ฐ•์กฐํ•˜๋Š” stepper ์ปดํฌ๋„ŒํŠธ์ž…๋‹ˆ๋‹ค. + +## ๊ตฌํ˜„ ์œ„์น˜ + +```text +src/components/stepper +โ”œโ”€ StepperUI.tsx +โ”œโ”€ index.ts +โ”œโ”€ types.ts +โ””โ”€ samples/ + โ””โ”€ BaseSample.tsx +``` + +## ํŠน์ง• + +- ๋‚ด๋ถ€์ ์œผ๋กœ `ProcessFlowUI`๋ฅผ ์žฌ์‚ฌ์šฉํ•ด ๋™์ผํ•œ ์ƒํƒœ ํ‘œํ˜„๊ณผ ์Šคํƒ€์ผ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- `horizontal`, `vertical` ๋‘ ๋ฐฉํ–ฅ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. +- `currentStepKey`๋งŒ ๋„˜๊ฒจ๋„ ํ˜„์žฌ ๋‹จ๊ณ„๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์™„๋ฃŒ/์ง„ํ–‰์ค‘/๋Œ€๊ธฐ ์ƒํƒœ๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. +- ๊ฐ ๋‹จ๊ณ„์— `status`๋ฅผ ์ง์ ‘ ์ง€์ •ํ•˜๋ฉด ์ˆ˜๋™ ์ƒํƒœ ์ œ์–ด๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +- `compact`, `showConnector`, `statusLabels`, `statusIcons`, `statusStyles`๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +## ๊ธฐ๋ณธ ์˜ˆ์‹œ + +```tsx + +``` + +## ์ฐธ๊ณ  + +- `Stepper`์™€ `StepperUI`๋ฅผ ํ•จ๊ป˜ exportํ•˜๋ฏ€๋กœ ๋‘˜ ์ค‘ ํ•˜๋‚˜๋ฅผ ์„ ํƒํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- `ProcessFlowUI`์˜ ๋ณ„์นญ ์„ฑ๊ฒฉ ์ปดํฌ๋„ŒํŠธ์ด๋ฏ€๋กœ ๋™์ž‘ ๊ทœ์น™๊ณผ ์Šคํƒ€์ผ ๊ณ„์—ด์€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค. +- ๋Œ€ํ‘œ ์ƒ˜ํ”Œ์€ `src/components/stepper/samples/BaseSample.tsx`๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. diff --git a/docs/components/window-ui.md b/docs/components/window-ui.md new file mode 100755 index 0000000..c308890 --- /dev/null +++ b/docs/components/window-ui.md @@ -0,0 +1,27 @@ +# Window UI + +## ๋ชฉ์  + +๋ถ€๋ชจ ์˜์—ญ ์•ˆ์—์„œ ์ด๋™ ๊ฐ€๋Šฅํ•œ ๋ชจ๋‹ฌ ์Šคํƒ€์ผ ์œˆ๋„์šฐ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + +## ํŠน์ง• + +- ํ—ค๋” ์ž‘์—…์ค„ ๋“œ๋ž˜๊ทธ ์ด๋™ +- ๋ถ€๋ชจ ์˜์—ญ ๋‚ด๋ถ€๋กœ ์ด๋™ ๋ฒ”์œ„ ์ œํ•œ +- ๋ชจ์„œ๋ฆฌ/๋ณ€ ๋ฆฌ์‚ฌ์ด์ฆˆ +- ๋ฆฌ์‚ฌ์ด์ฆˆ ํ…Œ๋‘๋ฆฌ ๋” ๋„“์€ ํžˆํŠธ์˜์—ญ +- ๋ฆฌ์‚ฌ์ด์ฆˆ ๋ณ€/๋ชจ์„œ๋ฆฌ ๋”๋ธ”ํด๋ฆญ ๋ฐ ๋”๋ธ”ํƒญ ์‹œ ํ•ด๋‹น ๋ฐฉํ–ฅ์œผ๋กœ ์ฆ‰์‹œ ํ™•์žฅ +- ์ตœ์†Œํ™” / ์ตœ๋Œ€ํ™” / ๋ณต์› +- ํ—ค๋” ๋”๋ธ”ํด๋ฆญ ์ตœ๋Œ€ํ™” ํ† ๊ธ€ + +## ์ฃผ์š” props + +- `title` +- `subtitle` +- `defaultFrame` +- `minWidth` +- `minHeight` + +## ์ƒ˜ํ”Œ + +- `src/components/window/samples/Sample.tsx` diff --git a/docs/features/plan-automation.md b/docs/features/plan-automation.md new file mode 100755 index 0000000..3c69e29 --- /dev/null +++ b/docs/features/plan-automation.md @@ -0,0 +1,144 @@ +# Plan ์ž๋™ํ™”์™€ ๊ตฌํ˜„ ๋ฐฉ์‹ + +## ๋ชฉ์  + +Plan ์ž๋™ํ™” ๊ธฐ๋Šฅ์˜ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ, API ์—ฐ๋™ ๋ฐฉ์‹, release ๊ฒ€์ˆ˜ ์—ฐ๊ณ„ ๋ฐฉ์‹์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ๊ตฌํ˜„ ์œ„์น˜ + +- ํ™”๋ฉด ์ง„์ž…: `src/app/main/MainContent.tsx` +- ๋ฉ”๋‰ด/๋ผ์šฐํŒ…: `src/app/main/routes.tsx` +- ๋ฉ”์ธ ๋ณด๋“œ: `src/features/planBoard/PlanBoardPage.tsx` +- release ๊ฒ€์ˆ˜: `src/features/planBoard/ReleaseReviewPage.tsx` +- ์Šค์ผ€์ค„: `src/features/planBoard/PlanSchedulePage.tsx` +- ์ฐจํŠธ: `src/features/planBoard/charts.tsx` +- API ํด๋ผ์ด์–ธํŠธ: `src/features/planBoard/api.ts` +- ๋น ๋ฅธ ํ•„ํ„ฐ: `src/features/planBoard/quickFilters.ts` +- ๋ฉ”๋ชจ ๋งˆ์Šคํ‚น: `src/features/planBoard/noteMasking.ts` + +## ๋ฐ์ดํ„ฐ ๋ชจ๋ธ + +ํ•ต์‹ฌ ํƒ€์ž…์€ `src/features/planBoard/types.ts`์— ์žˆ์Šต๋‹ˆ๋‹ค. + +- `PlanItem`: ๋ณด๋“œ์˜ ๊ธฐ๋ณธ ์ž‘์—… ํ•ญ๋ชฉ +- `PlanDraft`: ์ƒ์„ฑ/์ˆ˜์ •์šฉ ์ดˆ์•ˆ +- `PlanIssueHistory`: ์ด์Šˆ ์ด๋ ฅ +- `PlanActionHistory`: ์กฐ์น˜ ์ด๋ ฅ +- `PlanSourceWorkHistory`: ๋ธŒ๋žœ์น˜, diff, preview, ํŒŒ์ผ ์Šค๋ƒ…์ƒท ๋“ฑ ์†Œ์Šค ์ž‘์—… ์ฆ์  +- `PlanReleaseReview`: release ๊ฒ€์ˆ˜ ์ƒํƒœ์™€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ +- `PlanReleaseReviewBoardItem`: ๊ฒ€์ˆ˜ ํ™”๋ฉด์šฉ ํ•ฉ์„ฑ ๋ชจ๋ธ + +์ž๋™ํ™” ์œ ํ˜• ๊ฐ’์€ ๋‹ค์Œ ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +- `plan`: Markdown ์Šคํƒ€์ผ Plan ๋ฌธ์„œ ๋“ฑ๋ก ๋ฐ ์ ‘์ˆ˜์šฉ +- `auto_worker`: ์‹ค์ œ Codex ์ž๋™ ์ž‘์—… ์‹คํ–‰์šฉ +- `command_execution`, `non_source_work`: ๊ธฐ์กด ์‹คํ–‰ ๋ถ„๋ฅ˜ ์œ ์ง€ + +๊ธฐ์กด ์ €์žฅ๊ฐ’์ธ `plan_registration`, `general_development`๋Š” ์„œ๋ฒ„์—์„œ ๊ฐ๊ฐ `plan`, `auto_worker`๋กœ ์ •๊ทœํ™”ํ•ฉ๋‹ˆ๋‹ค. + +## API ์—ฐ๋™ ๋ฐฉ์‹ + +๊ธฐ๋ณธ API ๋ฒ ์ด์Šค URL ๊ทœ์น™: + +- `VITE_WORK_SERVER_URL`์ด ์žˆ์œผ๋ฉด ๊ทธ ๊ฐ’์„ ์‚ฌ์šฉ +- ์—†์œผ๋ฉด `/api` ์‚ฌ์šฉ +- `/api`๊ฐ€ ์‹คํŒจํ•˜๋ฉด ๋ธŒ๋ผ์šฐ์ € origin ๊ธฐ์ค€ `3100/api`๋กœ fallback ์žฌ์‹œ๋„ + +์š”์ฒญ ๊ณตํ†ต ๊ทœ์น™: + +- GET์€ ๊ธฐ๋ณธ `no-store` +- ํƒ€์ž„์•„์›ƒ 8์ดˆ +- ๋“ฑ๋ก๋œ ํ† ํฐ์ด ์žˆ์œผ๋ฉด `X-Access-Token` ํ—ค๋” ์ถ”๊ฐ€ +- ํด๋ผ์ด์–ธํŠธ ์‹๋ณ„ ํ—ค๋”๋Š” `appendClientIdHeader`๋กœ ์ถ”๊ฐ€ + +## ์ฃผ์š” ์—”๋“œํฌ์ธํŠธ + +- `POST /plan/setup`: ์ดˆ๊ธฐ ํ…Œ์ด๋ธ”/๊ตฌ์„ฑ ์ค€๋น„ +- `GET /plan/items`: Plan ๋ชฉ๋ก ์กฐํšŒ +- `POST /plan/items`: Plan ๋“ฑ๋ก +- `PATCH /plan/items/:id`: Plan ์ˆ˜์ • +- `DELETE /plan/items/:id`: Plan ์‚ญ์ œ +- `POST /plan/items/:id/actions/:action`: ์ƒํƒœ ์ „์ด/์žฌ์ฒ˜๋ฆฌ ์•ก์…˜ +- `GET /plan/items/:id/issues`: ์ด์Šˆ ์ด๋ ฅ +- `POST /plan/items/:id/issues/action`: ์ด์Šˆ ์กฐ์น˜ +- `GET /plan/items/:id/actions`: ์กฐ์น˜ ์ด๋ ฅ +- `POST /plan/items/:id/actions/note`: ์ถ”๊ฐ€ ์กฐ์น˜ ๋ฉ”๋ชจ +- `GET /plan/items/:id/source-works`: ์†Œ์Šค ์ž‘์—… ์ด๋ ฅ +- `GET /plan/items/:id/source-works/:sourceWorkId`: ํŠน์ • ์†Œ์Šค ์ž‘์—… ์ƒ์„ธ +- `GET /plan/release-reviews`: release ๊ฒ€์ˆ˜ ๋ชฉ๋ก +- `PATCH /plan/release-reviews/:planItemId`: release ๊ฒ€์ˆ˜ ์ƒํƒœ ์ €์žฅ + +## ๋น ๋ฅธ ํ•„ํ„ฐ ๊ทœ์น™ + +`quickFilters.ts`์—์„œ ๋‹ค์Œ ์กฐ๊ฑด์„ ๋ณ„๋„ ์œ ํ‹ธ๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +- `working`: ์ƒํƒœ๊ฐ€ `์ž‘์—…์ค‘` +- `release-pending-main`: `๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ`์ด๊ฑฐ๋‚˜ main ๋ฐ˜์˜ ๋Œ€๊ธฐ/์ง„ํ–‰/์‹คํŒจ ์ƒํƒœ +- `automation-failed`: ์›Œ์ปค ์‹คํŒจ ์ƒํƒœ ์ง‘ํ•ฉ์— ํฌํ•จ + +์ด ๊ตฌ์กฐ ๋•๋ถ„์— ์‚ฌ์ด๋“œ๋ฐ”, ๊ฒ€์ƒ‰, deep link๊ฐ€ ๊ฐ™์€ ๊ธฐ์ค€์„ ๊ณต์œ ํ•ฉ๋‹ˆ๋‹ค. + +## release ๊ฒ€์ˆ˜ ๋ฐฉ์‹ + +release ๊ฒ€์ˆ˜ ํ™”๋ฉด์€ Plan ๋ณธ๋ฌธ๊ณผ ๋ถ„๋ฆฌ๋˜์–ด ์žˆ์ง€๋งŒ ๊ฐ™์€ ๋ฐ์ดํ„ฐ ์ถ•์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +- ๊ฒ€์ˆ˜ ํ•„ํ„ฐ: ์ „์ฒด, main ๋Œ€๊ธฐ, ๊ฒ€์ˆ˜์™„๋ฃŒ, ์ˆ˜์ •ํ•„์š” +- ๊ฒ€์ˆ˜ ๋ฉ”๋ชจ ์ €์žฅ +- ๊ด€๋ จ ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ์ƒ˜ํ”Œ ๋ฐ”๋กœ ์—ด๊ธฐ +- ์ฒดํฌ๋œ ํŽ˜์ด์ง€/์ƒ˜ํ”Œ ID ๊ธฐ๋ฐ˜ ์ง„ํ–‰๋ฅ  ๊ณ„์‚ฐ +- ์ „์ฒด ๋Œ€์ƒ ํ™•์ธ ์‹œ ์ž๋™์œผ๋กœ `approved` +- ์ผ๋ถ€ ํ™•์ธ ๋˜๋Š” ๋ฉ”๋ชจ ์ž‘์„ฑ ์‹œ `reviewing` +- ์ˆ˜์ • ์š”์ฒญ ์ƒํƒœ์—์„œ ์ฒดํฌ๊ฐ€ ๋œ ๋๋‚œ ๊ฒฝ์šฐ `changes-requested` ์œ ์ง€ + +๊ฒ€์ˆ˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์—๋Š” ๋‹ค์Œ ๊ฐ’์ด ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- `summary` +- `pageSelectionIds` +- `checkedPageSelectionIds` +- `docIds` +- `componentIds` +- `widgetIds` + +## ์†Œ์Šค ์ž‘์—… ์ฆ์  + +Plan ์ƒ์„ธ์—์„œ๋Š” ์ตœ์‹  ์ž๋™ ์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ํƒญ ํ˜•ํƒœ๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +- ์ž‘์—…๋ž€ +- ์ „์ฒด์†Œ์Šค +- diff +- ์ปค๋งจ๋“œ ๋กœ๊ทธ +- ์ฆ์  Preview +- ํŒŒ์ผ ๋ชฉ๋ก + +`PlanSourceWorkHistory`์—๋Š” ๋ธŒ๋žœ์น˜๋ช…, ์ปค๋ฐ‹ ํ•ด์‹œ, preview URL, ๋ณ€๊ฒฝ ํŒŒ์ผ, diff ํ…์ŠคํŠธ, ํŒŒ์ผ ์Šค๋ƒ…์ƒท์ด ํฌํ•จ๋ฉ๋‹ˆ๋‹ค. + +Codex ์‹คํ–‰ ๋กœ๊ทธ์— `tokens used`๊ฐ€ ์žกํžˆ๋ฉด source work ๊ธฐ๋ก๊ณผ ์ƒ์„ธ ์ƒ๋‹จ ์ƒํƒœ ์˜์—ญ์— ํ•จ๊ป˜ ํ‘œ๊ธฐํ•ฉ๋‹ˆ๋‹ค. + +## ์ฐจํŠธ ์ง‘๊ณ„ ๋ฐฉ์‹ + +`charts.tsx`๋Š” Plan ์ „์ฒด ๋ชฉ๋ก์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์™€ ์ตœ๊ทผ ์„ฑ๊ณผ๋ฅผ ์ง‘๊ณ„ํ•ฉ๋‹ˆ๋‹ค. + +- ์ผ๋ณ„ ์ตœ๊ทผ 7์ผ +- ์ฃผ๋ณ„ ์ตœ๊ทผ 8์ฃผ +- ๋“ฑ๋ก ์ˆ˜ +- ๊ธฐ๋Šฅํ™•์ธ์™„๋ฃŒ ์ˆ˜ +- ์ž‘์—…์™„๋ฃŒ ์ˆ˜ +- main ๋ฐ˜์˜ ์ˆ˜ + +ํ˜„์žฌ ์Šค๋ƒ…์ƒท์€ ์ „์ฒด, ๋“ฑ๋ก, ์ž‘์—…์ค‘, ๊ธฐ๋Šฅํ™•์ธ์™„๋ฃŒ, ์™„๋ฃŒ, main๋ฐ˜์˜ ๊ฐœ์ˆ˜๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. + +## ๊ฒ€์ƒ‰/์ง„์ž… ์—ฐ๊ณ„ + +ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ์˜ต์…˜์€ `src/app/main/mainView/searchOptions.ts`์—์„œ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. + +- `์ž๋™ํ™” / ์ž๋™ํ™”` +- `์ž๋™ํ™” / plan` +- `์ž๋™ํ™” / release ๊ฒ€์ˆ˜` + +๊ฒ€์ƒ‰์—์„œ ์„ ํƒํ•˜๋ฉด ํ•ด๋‹น ๋ฉ”๋‰ด๋กœ ์ด๋™ํ•˜๊ณ  ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋ฌธ์„œ/์ปดํฌ๋„ŒํŠธ ์œ„์น˜๋กœ ์Šคํฌ๋กคํ•ฉ๋‹ˆ๋‹ค. + +## ์œ ์ง€๋ณด์ˆ˜ ๋ฉ”๋ชจ + +- ์ƒํƒœ ๋ฌธ์ž์—ด๊ณผ ์›Œ์ปค ์ƒํƒœ ๋ฌธ์ž์—ด์€ ๋ฌธ์ž์—ด ์ƒ์ˆ˜ ๊ธฐ๋ฐ˜์ด๋ผ ๋ฐฑ์—”๋“œ์™€ ๊ฐ’์ด ์–ด๊ธ‹๋‚˜์ง€ ์•Š๊ฒŒ ๊ฐ™์ด ๊ด€๋ฆฌํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. +- ์Šค์ผ€์ค„ API๋Š” ์—ฌ๋Ÿฌ ๊ฒฝ๋กœ ํ›„๋ณด๋ฅผ ์ˆœ์ฐจ ์‹œ๋„ํ•˜๋ฏ€๋กœ ์„œ๋ฒ„ ๊ฒฝ๋กœ ํ†ตํ•ฉ ์‹œ `api.ts` ์ •๋ฆฌ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. +- release ๊ฒ€์ˆ˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ ์Šคํ‚ค๋งˆ๋ฅผ ๋Š˜๋ฆด ๋•Œ๋Š” `ReleaseReviewPage`์˜ ์ง„ํ–‰๋ฅ  ๊ณ„์‚ฐ ๊ทœ์น™๋„ ๊ฐ™์ด ์ ๊ฒ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. diff --git a/docs/features/plan-board-review.md b/docs/features/plan-board-review.md new file mode 100755 index 0000000..bf27ff4 --- /dev/null +++ b/docs/features/plan-board-review.md @@ -0,0 +1,114 @@ +# Plan ์ž๋™ํ™” ๋ณด๋“œ + +## ๋ชฉ์  + +`src/features/planBoard`๋Š” Plan ๋“ฑ๋ก๋ถ€ํ„ฐ ์ž๋™ ์ž‘์—…, release ๊ฒ€์ˆ˜, main ๋™๊ธฐํ™” ์ „ ๋‹จ๊ณ„๊นŒ์ง€ ํ•œ ํ™”๋ฉด ํ๋ฆ„์œผ๋กœ ๊ด€๋ฆฌํ•˜๋Š” ๊ธฐ๋Šฅ ๋ฌถ์Œ์ž…๋‹ˆ๋‹ค. +ํ˜„์žฌ ๋ฌธ์„œ๋Š” ๊ธฐ์กด ๊ฐœ์„  ์ œ์•ˆ ๋ฉ”๋ชจ๋ฅผ ๋Œ€์‹ ํ•ด ์‹ค์ œ ๊ตฌํ˜„ ๊ธฐ์ค€์˜ ์šด์˜ ๋ฌธ์„œ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +## ํ™”๋ฉด ๊ตฌ์„ฑ + +- `all`: ์ „์ฒด ์ž๋™ํ™” ๋ชฉ๋ก +- `in-progress`: ์™„๋ฃŒ ์ „ ํ•ญ๋ชฉ ์ค‘์‹ฌ ๋ชฉ๋ก +- `error`: ์—ด๋ฆฐ ์ด์Šˆ๊ฐ€ ์žˆ๋Š” ํ•ญ๋ชฉ ์ค‘์‹ฌ ๋ชฉ๋ก +- `done`: release ์™„๋ฃŒ ๊ธฐ์ค€ ๋ชฉ๋ก +- `board`: ์ƒ์„ธ ํŽธ์ง‘๊ณผ ์ด๋ ฅ ์กฐํšŒ ์ค‘์‹ฌ ๋ณด๋“œ +- `release-review`: release ๊ฒ€์ˆ˜ ์ „์šฉ ํ™”๋ฉด +- `charts`: ์ตœ๊ทผ ์ž‘์—… ์ถ”์ด ์ฐจํŠธ +- `schedule`: ๋ฐ˜๋ณต ๋“ฑ๋ก ์Šค์ผ€์ค„ ๊ด€๋ฆฌ +- `history`: ํ–ฅํ›„ ์ด๋ ฅ ํ™•์žฅ ๋ฉ”๋‰ด์šฉ ์˜์—ญ + +๋ผ์šฐํŒ…๊ณผ ๋ฉ”๋‰ด ์ •์˜๋Š” `src/app/main/routes.tsx`, `src/app/main/MainContent.tsx`์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ๋ฌธ์„œ ๋ฐ˜์˜ ๋ฐฉ์‹ + +- ์ด ๋ฌธ์„œ๋ฅผ ํฌํ•จํ•œ `docs/features/*.md`๋Š” ๋ฉ”์ธ ์•ฑ `Docs / ๊ธฐ๋Šฅ๋ฌธ์„œ` ๋ฉ”๋‰ด์—์„œ ์ž๋™ ์ˆ˜์ง‘๋ฉ๋‹ˆ๋‹ค. +- ๋ฌธ์„œ ์ถ”๊ฐ€๋งŒ์œผ๋กœ ๋ณ„๋„ ๋ผ์šฐํŠธ๋ฅผ ๋งŒ๋“ค์ง€๋Š” ์•Š์œผ๋ฉฐ, `Docs` ํ™”๋ฉด์˜ markdown ๋ชฉ๋ก์— ํ•ฉ๋ฅ˜ํ•˜๋Š” ๋ฐฉ์‹์ž…๋‹ˆ๋‹ค. +- ๋…ธ์ถœ ์ˆœ์„œ์™€ ํด๋” ๋ผ๋ฒจ์€ `src/app/main/layout/useMainLayoutData.ts`, `src/app/main/routes.tsx`์—์„œ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. + +## ์ƒํƒœ ๋ชจ๋ธ + +๊ธฐ๋ณธ ์ƒํƒœ๋Š” `src/features/planBoard/types.ts`์— ์ •์˜๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +- `๋“ฑ๋ก` +- `์ž‘์—…์ค‘` +- `์ž‘์—…์™„๋ฃŒ` +- `๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ` +- `์™„๋ฃŒ` + +์‹ค์ œ ์ž๋™ํ™” ์ง„ํ–‰์€ ๋ณ„๋„ `workerStatus`๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ๋Œ€ํ‘œ๊ฐ’์€ ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. + +- ์ง„ํ–‰: `๋ธŒ๋žœ์น˜์ƒ์„ฑ์ค‘`, `์ž๋™์ž‘์—…์ค‘`, `release๋ฐ˜์˜์ค‘`, `main๋ฐ˜์˜์ค‘` +- ๋Œ€๊ธฐ: `๋ธŒ๋žœ์น˜์ค€๋น„`, `release๋ฐ˜์˜๋Œ€๊ธฐ`, `main๋ฐ˜์˜๋Œ€๊ธฐ` +- ์‹คํŒจ: `๋ธŒ๋žœ์น˜์‹คํŒจ`, `์ž๋™์ž‘์—…์‹คํŒจ`, `release๋ฐ˜์˜์‹คํŒจ`, `main๋ฐ˜์˜์‹คํŒจ` + +์ฆ‰, ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์ด๋Š” ์—…๋ฌด ์ƒํƒœ์™€ ์ž๋™ ์›Œ์ปค ์ƒํƒœ๋ฅผ ๋ถ„๋ฆฌํ•ด์„œ ํ‘œํ˜„ํ•ฉ๋‹ˆ๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ ํ๋ฆ„ + +1. ์ž‘์—…์„ ๋“ฑ๋กํ•˜๋ฉด Plan ํ•ญ๋ชฉ์ด ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค. +2. `์ž‘์—…์‹œ์ž‘` ๋˜๋Š” ์ž๋™ํ™”์— ๋”ฐ๋ผ ๋ธŒ๋žœ์น˜ ์ค€๋น„์™€ ์ž‘์—…์ด ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค. +3. ์ž‘์—…์ด ์™„๋ฃŒ๋˜๋ฉด release ๋ฐ˜์˜ ์ƒํƒœ์™€ main ๋ฐ˜์˜ ๋Œ€๊ธฐ ์—ฌ๋ถ€๊ฐ€ ์ถ”์ ๋ฉ๋‹ˆ๋‹ค. +4. release ๊ฒ€์ˆ˜ ํ™”๋ฉด์—์„œ ์ƒ˜ํ”Œ/์œ„์ ฏ/๋ณ€๊ฒฝ ํŒŒ์ผ์„ ํ™•์ธํ•˜๊ณ  ๊ฒ€์ˆ˜ ์ƒํƒœ๋ฅผ ๊ฐฑ์‹ ํ•ฉ๋‹ˆ๋‹ค. +5. main ๋ฐ˜์˜์ด ๋๋‚˜๋ฉด ์ตœ์ข… ์™„๋ฃŒ ํ๋ฆ„์œผ๋กœ ์ •๋ฆฌ๋ฉ๋‹ˆ๋‹ค. + +## ๋ชฉ๋ก ๊ธฐ๋Šฅ + +`PlanBoardPage.tsx` ๊ธฐ์ค€์œผ๋กœ ๋‹ค์Œ ๊ธฐ๋Šฅ์ด ๊ตฌํ˜„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +- ๊ฒ€์ƒ‰: `workId`, ๋ฉ”๋ชจ, ์ƒํƒœ, ๋ธŒ๋žœ์น˜, ์›Œ์ปค ์ƒํƒœ, ์ด์Šˆ ํƒœ๊ทธ ๊ฒ€์ƒ‰ +- ๋ณด์กฐ ํ•„ํ„ฐ: ์ž‘์—…์ž ์ƒํƒœ, release ์ƒํƒœ, main ์ƒํƒœ, ์ด์Šˆ ์ƒํƒœ +- ๋น ๋ฅธ ํ•„ํ„ฐ: ํ˜„์žฌ ์ž‘์—…์ค‘, ํ˜„์žฌ release ์ƒํƒœ, ํ˜„์žฌ ์ž๋™ํ™” ์‹คํŒจ +- ์ •๋ ฌ: ์ง„ํ–‰ ๋ชฉ๋ก์—์„œ๋Š” ์ž๋™ํ™” ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋จผ์ € ์ ์šฉํ•˜๊ณ , ๊ทธ ์™ธ์—๋Š” ์ตœ๊ทผ ๊ฐฑ์‹ ์ˆœ ์ •๋ ฌ +- ํŽ˜์ด์ง€๋„ค์ด์…˜: ๋ชฉ๋ก 10๊ฐœ ๋‹จ์œ„ + +## ์ƒ์„ธ ๊ธฐ๋Šฅ + +์„ ํƒํ•œ Plan ํ•ญ๋ชฉ์€ ์˜ค๋ฒ„๋ ˆ์ด์—์„œ ์ƒ์„ธ ํ™•์ธ๊ณผ ํ›„์† ์กฐ์น˜๋ฅผ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +- ์›๋ณธ ์š”์ฒญ ๋ณด๊ธฐ +- ์ž‘์—… ์ฒดํฌ๋ฆฌ์ŠคํŠธ +- ๋ฆด๋ฆฌ์ฆˆ ์ค€๋น„ ์š”์•ฝ +- ์†Œ์Šค ์ž‘์—… ๋‚ด์—ญ +- ์กฐ์น˜ / ์ด์Šˆ ์ด๋ ฅ +- ์ถ”๊ฐ€ ์กฐ์น˜ ๊ธฐ๋ก +- ์ด์Šˆ ์กฐ์น˜ ๊ธฐ๋ก +- ์ž‘์—… ์†Œ์Šค / diff / command log / ์ฆ์  / ํŒŒ์ผ ๋ทฐ์–ด + +์›๋ณธ ์š”์ฒญ์€ `startedAt` ์ดํ›„ ์ž ๊ธฐ๋ฉฐ, ์ดํ›„ ๋ณ€๊ฒฝ์€ ์กฐ์น˜ ์ด๋ ฅ์œผ๋กœ ๋ˆ„์ ํ•ฉ๋‹ˆ๋‹ค. + +## ์‹คํ–‰ ์•ก์…˜ + +์ƒํƒœ์™€ `workerStatus`์— ๋”ฐ๋ผ ๋‹ค์Œ ์•ก์…˜ ๋ฒ„ํŠผ์ด ์กฐ๊ฑด๋ถ€๋กœ ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค. + +- `์ž‘์—…์‹œ์ž‘` +- `์ž‘์—…์™„๋ฃŒ ์ฒ˜๋ฆฌ` +- `๋ธŒ๋žœ์น˜ ์žฌ์‹œ๋„` +- `์ž‘์—… ์žฌ์ฒ˜๋ฆฌ` +- `release ๋ฐ˜์˜ ์žฌ์‹œ๋„` +- `์ž‘์—…์ทจ์†Œ` +- `main ์ผ๊ด„ ๋ฐ˜์˜ ์š”์ฒญ` + +์„ธ๋ถ€ ์•ก์…˜์€ `/plan/items/:id/actions/:action` API๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. + +## ์ž๋™ ์ƒˆ๋กœ๊ณ ์นจ๊ณผ ์•Œ๋ฆผ + +- ์ž๋™ํ™”๊ฐ€ ์ง„ํ–‰/๋Œ€๊ธฐ ์ƒํƒœ์ผ ๋•Œ๋งŒ ์ž๋™ ์กฐํšŒ ํƒ€์ด๋จธ๊ฐ€ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. +- ์ฃผ๊ธฐ๋Š” `appConfig.automation.autoRefreshIntervalSeconds`๋ฅผ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. +- ๊ธธ๊ฒŒ ๋ˆŒ๋Ÿฌ ์ž๋™ ์กฐํšŒ On/Off๋ฅผ ์ „ํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์„ค์ •๊ฐ’์— ๋”ฐ๋ผ ์ž๋™ํ™” ์‹œ์ž‘/์‹คํŒจ ๋ฉ”์‹œ์ง€๋ฅผ toast๋กœ ์•Œ๋ฆฝ๋‹ˆ๋‹ค. + +## ๊ถŒํ•œ ์ฒ˜๋ฆฌ + +๊ถŒํ•œ ํ† ํฐ์ด ์—†๋Š” ๊ฒฝ์šฐ ์กฐํšŒ ์ค‘์‹ฌ์œผ๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. + +- ์ˆ˜์ •/์‚ญ์ œ/์กฐ์น˜/์Šค์ผ€์ค„ ์ €์žฅ ๋น„ํ™œ์„ฑํ™” +- ๋ฏผ๊ฐํ•œ ์š”์ฒญ ๋ฉ”๋ชจ๋Š” ๋งˆ์Šคํ‚น +- ์†Œ์Šค ์ž‘์—… ์ƒ์„ธ/์ฆ์  ํ™•์ธ ์ œํ•œ + +ํ† ํฐ ์ฒ˜๋ฆฌ์™€ ํ—ค๋” ๋ถ€์ฐฉ์€ `src/app/main/tokenAccess.ts`, `src/features/planBoard/api.ts`์—์„œ ๋‹ด๋‹นํ•ฉ๋‹ˆ๋‹ค. + +## ์—ฐ๊ด€ ๋ฌธ์„œ + +- ์ž๋™ํ™”/๊ตฌํ˜„ ๋ฐฉ์‹: `docs/features/plan-automation.md` +- ์Šค์ผ€์ค„ ์‚ฌ์šฉ๋ฒ•: `docs/features/plan-schedule.md` +- ํ™œ์šฉ ๊ฐ€์ด๋“œ: `docs/features/plan-usage.md` diff --git a/docs/features/plan-schedule.md b/docs/features/plan-schedule.md new file mode 100755 index 0000000..43b1b6d --- /dev/null +++ b/docs/features/plan-schedule.md @@ -0,0 +1,98 @@ +# Plan ์Šค์ผ€์ค„ ์‚ฌ์šฉ๋ฒ• + +## ๋ชฉ์  + +๋ฐ˜๋ณต์ ์œผ๋กœ ๋“ฑ๋ก๋˜๋Š” ์ž๋™ํ™” ์š”์ฒญ์„ ์ˆ˜๋™ ์ž…๋ ฅ ์—†์ด ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•œ ์Šค์ผ€์ค„ ํ™”๋ฉด ์‚ฌ์šฉ๋ฒ•๊ณผ ์ œ์•ฝ์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ๊ตฌํ˜„ ์œ„์น˜ + +- ํ™”๋ฉด: `src/features/planBoard/PlanSchedulePage.tsx` +- API: `src/features/planBoard/api.ts` + +## ์ œ๊ณต ๊ธฐ๋Šฅ + +- ์Šค์ผ€์ค„ ๋ชฉ๋ก ์กฐํšŒ +- ์‹ ๊ทœ ์Šค์ผ€์ค„ ๋“ฑ๋ก +- ๊ธฐ์กด ์Šค์ผ€์ค„ ์ˆ˜์ •/์‚ญ์ œ +- ์ฆ‰์‹œ ์‹คํ–‰ ์˜ต์…˜ ์„ค์ • +- ํ™œ์„ฑ/๋น„ํ™œ์„ฑ ์ „ํ™˜ +- ๋ฐ˜๋ณต ์ฃผ๊ธฐ ๋˜๋Š” ๋งค์ผ ์‹คํ–‰ ์‹œ๊ฐ„ ์„ค์ • +- ๋‹ค์Œ ์‹คํ–‰ ์˜ˆ์ • ์‹œ๊ฐ ๊ณ„์‚ฐ ํ‘œ์‹œ + +## ์ž…๋ ฅ ํ•ญ๋ชฉ + +- `workId`: ๋ฐ˜๋ณต ๋“ฑ๋กํ•  ์ž‘์—… ID +- `note`: ๋งค๋ฒˆ ์ƒ์„ฑ๋  ์š”์ฒญ ๋ฉ”๋ชจ +- `automationType`: ์ž๋™ํ™” ์œ ํ˜• + - `plan`: Markdown ์Šคํƒ€์ผ Plan ๋ฌธ์„œ ๋“ฑ๋ก/์ ‘์ˆ˜ + - `auto_worker`: ์‹ค์ œ ์ž๋™ ์ž‘์—… ์‹คํ–‰ + - `command_execution`, `non_source_work`: ๊ธฐ์กด ๋ถ„๋ฅ˜ ์œ ์ง€ +- `releaseTarget`: ๋ฐ˜์˜ ๋Œ€์ƒ ๋ธŒ๋žœ์น˜ +- `jangsingProcessingRequired`: ๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ ํ•„์š” ์—ฌ๋ถ€ +- `autoDeployToMain`: main ์ž๋™ ๋ฐ˜์˜ ๋Œ€์ƒ ์—ฌ๋ถ€ +- `enabled`: ์Šค์ผ€์ค„ ์‚ฌ์šฉ ์—ฌ๋ถ€ +- `immediateRunEnabled`: ์ƒ์„ฑ ์งํ›„ ๋ฐ”๋กœ ๋“ฑ๋ก ํ—ˆ์šฉ ์—ฌ๋ถ€ + +## ์Šค์ผ€์ค„ ๋ชจ๋“œ + +### 1. ๋ฐ˜๋ณต ์ฃผ๊ธฐ + +`interval` ๋ชจ๋“œ์ž…๋‹ˆ๋‹ค. + +- ๊ฐ’ + ๋‹จ์œ„๋กœ ๋ฐ˜๋ณต ์ฃผ๊ธฐ ์„ค์ • +- ๋‹จ์œ„: `minute`, `hour`, `day`, `week`, `month` +- ๋‚ด๋ถ€ ์ €์žฅ์€ `repeatIntervalMinutes` ๊ธฐ์ค€ + +### 2. ๋งค์ผ ์‹œ๊ฐ„ + +`daily` ๋ชจ๋“œ์ž…๋‹ˆ๋‹ค. + +- `HH:mm` ํ˜•์‹ ์‹œ๊ฐ„ ์‚ฌ์šฉ +- ์‹œ๊ฐ„ ๊ณ„์‚ฐ์€ `Asia/Seoul` ๊ธฐ์ค€ +- ํ•˜๋ฃจ 1ํšŒ ๋“ฑ๋ก์— ์ ํ•ฉ + +## ์œ ํšจ์„ฑ ๊ทœ์น™ + +ํ˜„์žฌ ํ™”๋ฉด์—์„œ ๋‹ค์Œ ๊ฒ€์‚ฌ๋ฅผ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +- ์ž‘์—… ID ํ•„์ˆ˜ +- ๋ฉ”๋ชจ ํ•„์ˆ˜ +- ๊ฐ™์€ ์ž‘์—… ID ์ค‘๋ณต ๊ธˆ์ง€ +- `interval` ๋ชจ๋“œ ์ตœ์†Œ 10๋ถ„ ์ด์ƒ +- ๋น„ํ™œ์„ฑ ์Šค์ผ€์ค„์€ ์ž๋™ ๋“ฑ๋ก๋˜์ง€ ์•Š์Œ์„ ๊ฒฝ๊ณ  + +## ๋‹ค์Œ ์‹คํ–‰ ์‹œ๊ฐ ๊ณ„์‚ฐ + +๋‹ค์Œ ์‹คํ–‰ ์‹œ๊ฐ์€ ํ™”๋ฉด์—์„œ ์ฆ‰์„ ๊ณ„์‚ฐํ•ด ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. + +- ๋น„ํ™œ์„ฑ ์ƒํƒœ๋ฉด `์ค‘์ง€` +- ์ง์ „ ๋“ฑ๋ก ์ด๋ ฅ์ด ์—†๊ณ  ์ฆ‰์‹œ ์‹คํ–‰์ด ์ผœ์ ธ ์žˆ์œผ๋ฉด ํ˜„์žฌ ์‹œ๊ฐ ๊ธฐ์ค€ +- `daily`๋Š” ์˜ค๋Š˜ KST ์‹คํ–‰ ์—ฌ๋ถ€๋ฅผ ๋ณด๊ณ  ์˜ค๋Š˜ ๋˜๋Š” ๋‹ค์Œ ๋‚  ์‹œ๊ฐ ๊ฒฐ์ • +- `interval`์€ ๋งˆ์ง€๋ง‰ ๋“ฑ๋ก ์‹œ๊ฐ ๋˜๋Š” ์ƒ์„ฑ ์‹œ๊ฐ ๊ธฐ์ค€์œผ๋กœ ๋‹ค์Œ ์ฃผ๊ธฐ๋ฅผ ๊ณ„์‚ฐ + +## ์ถ”์ฒœ ์šด์˜ ๋ฐฉ์‹ + +- ์ž์ฃผ ๋ฐ˜๋ณต๋˜๋Š” ์šด์˜ ์ž‘์—…์€ ๊ณ ์ • `workId`๋กœ ๋“ฑ๋ก +- ์‚ฌ๋žŒ์ด ๊ฒ€ํ† ํ•ด์•ผ ํ•˜๋Š” ์ž‘์—…์€ `autoDeployToMain`์„ ๋„๊ณ  release ๊ฒ€์ˆ˜ ๋‹จ๊ณ„์—์„œ ํ™•์ธ +- ๋‹จ์ˆœ ์•Œ๋ฆผ์„ฑ/๋ฐ˜๋ณต์„ฑ ์ž‘์—…์€ `immediateRunEnabled`๋ฅผ ์ผœ์„œ ๋ˆ„๋ฝ ์—†์ด ์‹œ์ž‘ +- ์งง์€ ์ฃผ๊ธฐ ์Šค์ผ€์ค„์€ 10๋ถ„ ์ด์ƒ์œผ๋กœ ์œ ์ง€ํ•ด ์ค‘๋ณต ์ƒ์„ฑ ์œ„ํ—˜์„ ๋‚ฎ์ถค + +## API ๊ฒฝ๋กœ ๋ฉ”๋ชจ + +์Šค์ผ€์ค„ API๋Š” ์„œ๋ฒ„ ๊ตฌํ˜„ ์ฐจ์ด๋ฅผ ํก์ˆ˜ํ•˜๊ธฐ ์œ„ํ•ด ์—ฌ๋Ÿฌ ๊ฒฝ๋กœ๋ฅผ ์ˆœ์ฐจ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค. + +- `/plan/scheduled-tasks` +- `/plan/schedule/tasks` +- `/plan/schedule` +- `/plan/schedules` +- `/plans/...` ๋ณ€ํ˜• ๊ฒฝ๋กœ + +์„œ๋ฒ„ ๊ฒฝ๋กœ๊ฐ€ ๊ณ ์ •๋˜๋ฉด ํ›„๋ณด ๊ฒฝ๋กœ๋ฅผ ์ค„์—ฌ๋„ ๋ฉ๋‹ˆ๋‹ค. + +## ํ…Œ์ŠคํŠธ ํฌ์ธํŠธ + +- interval/daily ์ „ํ™˜ ์‹œ ๊ฐ’ ๋ณด์กด์ด ์ž์—ฐ์Šค๋Ÿฌ์šด์ง€ ํ™•์ธ +- ๊ฐ™์€ `workId` ์ค‘๋ณต ์ €์žฅ์ด ๋ง‰ํžˆ๋Š”์ง€ ํ™•์ธ +- KST ๊ธฐ์ค€ ๋‹ค์Œ ์‹คํ–‰ ์‹œ๊ฐ์ด ๊ธฐ๋Œ€์™€ ์ผ์น˜ํ•˜๋Š”์ง€ ํ™•์ธ +- ๋น„ํ™œ์„ฑ ์Šค์ผ€์ค„์ด ์ž๋™ ์ƒ์„ฑ๋˜์ง€ ์•Š๋Š”์ง€ ํ™•์ธ +- ํ† ํฐ ์—†๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ์ €์žฅ/์‚ญ์ œ๊ฐ€ ์ œํ•œ๋˜๋Š”์ง€ ํ™•์ธ diff --git a/docs/features/plan-usage.md b/docs/features/plan-usage.md new file mode 100755 index 0000000..9607f40 --- /dev/null +++ b/docs/features/plan-usage.md @@ -0,0 +1,58 @@ +# Plan ํ™œ์šฉ ๊ฐ€์ด๋“œ + +## ๋ชฉ์  + +์šด์˜์ž, ๊ฒ€์ˆ˜์ž, ๊ฐœ๋ฐœ์ž๊ฐ€ ํ˜„์žฌ Plan ๊ธฐ๋Šฅ์„ ์–ด๋–ค ์ˆœ์„œ๋กœ ์จ์•ผ ํ•˜๋Š”์ง€ ์ •๋ฆฌํ•œ ์‹ค๋ฌด ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค. + +## ๋น ๋ฅธ ์ง„์ž… + +- ์ „์ฒด ๋ชฉ๋ก ํ™•์ธ: `์ž๋™ํ™” / ์ž๋™ํ™”` +- ์ƒ์„ธ ์ฒ˜๋ฆฌ ์ค‘์‹ฌ: `์ž๋™ํ™” / plan` +- release ๊ฒ€์ˆ˜: `์ž๋™ํ™” / release ๊ฒ€์ˆ˜` +- ์ถ”์ด ํ™•์ธ: `์ž๋™ํ™” / ์ฐจํŠธ` +- ๋ฐ˜๋ณต ๋“ฑ๋ก: `์ž๋™ํ™” / ์Šค์ผ€์ค„` + +ํ†ตํ•ฉ ๊ฒ€์ƒ‰์—์„œ๋„ `plan`, `release ๊ฒ€์ˆ˜`, `์Šค์ผ€์ค„`๋กœ ๋ฐ”๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +## ์šด์˜์ž ๊ธฐ์ค€ ์‚ฌ์šฉ ์ˆœ์„œ + +1. `์ž๋™ํ™” / ์ž๋™ํ™”`์—์„œ ์ž‘์—…์„ ์ฐพ์Šต๋‹ˆ๋‹ค. +2. ํ•„์š”ํ•˜๋ฉด ๋น ๋ฅธ ํ•„ํ„ฐ๋กœ `ํ˜„์žฌ ์ž‘์—…์ค‘`, `ํ˜„์žฌ release ์ƒํƒœ`, `ํ˜„์žฌ ์ž๋™ํ™” ์‹คํŒจ`๋ฅผ ์ขํž™๋‹ˆ๋‹ค. +3. ์ƒ์„ธ ์˜ค๋ฒ„๋ ˆ์ด์—์„œ ํ˜„์žฌ ์ƒํƒœ, ์ด์Šˆ, ์ตœ๊ทผ ์†Œ์Šค ์ž‘์—…์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. +4. ์ƒํ™ฉ์— ๋งž๋Š” ์•ก์…˜์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. +5. ํ›„์† ์„ค๋ช…์€ ์กฐ์น˜ ๊ธฐ๋ก ๋˜๋Š” ์ด์Šˆ ์กฐ์น˜ ๊ธฐ๋ก์œผ๋กœ ๋‚จ๊น๋‹ˆ๋‹ค. + +## ๊ฒ€์ˆ˜์ž ๊ธฐ์ค€ ์‚ฌ์šฉ ์ˆœ์„œ + +1. `release ๊ฒ€์ˆ˜`์—์„œ `main ๋Œ€๊ธฐ` ํ•„ํ„ฐ๋ฅผ ์šฐ์„  ๋ด…๋‹ˆ๋‹ค. +2. ๊ฒ€์ˆ˜ ๋ฉ”๋ชจ์™€ ๋ณ€๊ฒฝ ํŒŒ์ผ, ๊ด€๋ จ ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ์ƒ˜ํ”Œ์„ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. +3. ํ™•์ธํ•œ ํ•ญ๋ชฉ์€ ์ฒดํฌ ์ƒํƒœ๋กœ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค. +4. ์ด์ƒ ์—†์œผ๋ฉด `๊ฒ€์ˆ˜์™„๋ฃŒ`, ์ˆ˜์ •์ด ํ•„์š”ํ•˜๋ฉด `์ˆ˜์ •ํ•„์š”`๋กœ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + +## ๊ฐœ๋ฐœ์ž ๊ธฐ์ค€ ํ™•์ธ ํฌ์ธํŠธ + +- ์‹คํŒจ ์ƒํƒœ๋ฉด `workerStatus`์™€ `lastError`๋ฅผ ๋จผ์ € ํ™•์ธ +- ๋ธŒ๋žœ์น˜ ๋ˆ„๋ฝ ๊ณ„์—ด ์‹คํŒจ๋Š” `๋ธŒ๋žœ์น˜ ์žฌ์‹œ๋„` ๋˜๋Š” `์ž‘์—… ์žฌ์ฒ˜๋ฆฌ` ํŒ๋‹จ +- release/main ๋ฐ˜์˜ ๊ด€๋ จ ๋ฌธ์ œ๋Š” ์ƒ์„ธ์˜ ์†Œ์Šค ์ž‘์—… ๋‚ด์—ญ๊ณผ diff๋ฅผ ๋จผ์ € ํ™•์ธ +- ๋ฉ”๋ชจ ์›๋ฌธ์ด ์ž ๊ฒจ ์žˆ์œผ๋ฉด ์ถ”๊ฐ€ ์กฐ์น˜ ๊ธฐ๋ก์œผ๋กœ ์ •์ • ์‚ฌ์œ ๋ฅผ ๋‚จ๊น€ + +## ๊ถŒํ•œ ์—†๋Š” ์‚ฌ์šฉ์ž ๋™์ž‘ + +- ๋ชฉ๋ก๊ณผ ๊ฒ€์ˆ˜ ํ˜„ํ™ฉ์€ ์กฐํšŒ ๊ฐ€๋Šฅ +- ๋ฏผ๊ฐ ๋ฉ”๋ชจ๋Š” ๋งˆ์Šคํ‚น +- ์ƒ์„ธ ์ˆ˜์ •/์‚ญ์ œ/์กฐ์น˜ ์‹คํ–‰์€ ์ œํ•œ +- ์†Œ์Šค ์ž‘์—… ์ƒ์„ธ์™€ ์ฆ์  ํ™•์ธ๋„ ์ œํ•œ๋  ์ˆ˜ ์žˆ์Œ + +## ๋ฌธ์„œ์™€ ํ™”๋ฉด์„ ํ•จ๊ป˜ ์šด์˜ํ•˜๋Š” ๋ฐฉ๋ฒ• + +- ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์‹œ `docs/features`๋ฅผ ๋จผ์ € ๊ฐฑ์‹  +- ์ƒˆ ๋ฌธ์„œ๋ฅผ ๋งŒ๋“ค๋ฉด ์•ฑ `Docs / ๊ธฐ๋Šฅ๋ฌธ์„œ`์— ์ž๋™ ๋…ธ์ถœ๋˜๋Š”์ง€ ํ•จ๊ป˜ ํ™•์ธ +- ์ฆ์ ์ด ํ•„์š”ํ•œ ๋ณ€๊ฒฝ์€ Plan ์ƒ์„ธ์˜ Preview, diff, ํŒŒ์ผ ๋ชฉ๋ก์œผ๋กœ ํ™•์ธ +- ์ž‘์—…์ผ์ง€์—๋Š” ์–ด๋–ค ๊ธฐ๋Šฅ ๋ฌธ์„œ๋ฅผ ์™œ ์ˆ˜์ •ํ–ˆ๋Š”์ง€ ํ•จ๊ป˜ ๋‚จ๊น€ + +## ์ถ”์ฒœ ๋ฌธ์„œ ์ฝ๊ธฐ ์ˆœ์„œ + +1. `docs/features/plan-board-review.md` +2. `docs/features/plan-automation.md` +3. `docs/features/plan-schedule.md` +4. `docs/features/plan-usage.md` diff --git a/docs/features/project-setup.md b/docs/features/project-setup.md new file mode 100755 index 0000000..786a648 --- /dev/null +++ b/docs/features/project-setup.md @@ -0,0 +1,100 @@ +# ํ”„๋กœ์ ํŠธ ๊ตฌ์„ฑ ๊ฐœ์š” + +## ๋ชฉ์  + +ํ˜„์žฌ ์ €์žฅ์†Œ์˜ ํ™”๋ฉด ๊ตฌ์กฐ์™€ ๋ฌธ์„œ ์ฒด๊ณ„๋ฅผ ๋น ๋ฅด๊ฒŒ ํŒŒ์•…ํ•˜๊ธฐ ์œ„ํ•œ ์ตœ์‹  ๊ฐœ์š” ๋ฌธ์„œ์ž…๋‹ˆ๋‹ค. + +## ๊ธฐ์ˆ  ์Šคํƒ + +- React +- Vite +- TypeScript +- Ant Design +- Recharts +- React Router + +## ์ตœ์ƒ์œ„ ์•ฑ ๊ตฌ์กฐ + +- `src/app/main`: ๋ฉ”์ธ ์•ฑ ํ”„๋ ˆ์ž„, ์ƒ๋‹จ ๋ฉ”๋‰ด, ์‚ฌ์ด๋“œ๋ฐ”, ๋ณธ๋ฌธ, ๊ฒ€์ƒ‰ ์—ฐ๋™ +- `src/features`: ํ”„๋กœ์ ํŠธ ์ „์šฉ ๊ธฐ๋Šฅ ํ™”๋ฉด +- `src/components`: ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ UI ์ปดํฌ๋„ŒํŠธ +- `src/widgets`: ์ƒ˜ํ”Œ/์œ„์ ฏ ๋‹จ์œ„ UI +- `docs`: ๊ธฐ๋Šฅ/์ปดํฌ๋„ŒํŠธ/์ž‘์—…์ผ์ง€ ๋ฌธ์„œ +- `etc/servers/work-server`: Plan API ์—ฐ๋™ ์„œ๋ฒ„ ์ž์‚ฐ + +## ํ˜„์žฌ ์ฃผ์š” ๊ธฐ๋Šฅ ์ถ• + +### Docs + +- `docs/**/*.md`๋ฅผ ์ˆ˜์ง‘ํ•ด ๋ฌธ์„œ ํ™”๋ฉด์— ๋…ธ์ถœ +- ์ž‘์—…์ผ์ง€, ๊ธฐ๋Šฅ ๋ฌธ์„œ, ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ๋ฅผ ๊ฐ™์€ ํ๋ฆ„์œผ๋กœ ํƒ์ƒ‰ +- `docs/features` ์•„๋ž˜ ๋ฌธ์„œ๋Š” `Docs / ๊ธฐ๋Šฅ๋ฌธ์„œ` ๋ฉ”๋‰ด์—์„œ ๋™์ ์œผ๋กœ ํ™•์ธ ๊ฐ€๋Šฅ + +### APIs + +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ +- ์œ„์ ฏ ์ƒ˜ํ”Œ + +### Plans + +- Plan ์ž๋™ํ™” ๋ชฉ๋ก/์ƒ์„ธ +- release ๊ฒ€์ˆ˜ +- ์ฐจํŠธ +- ์Šค์ผ€์ค„ +- ํžˆ์Šคํ† ๋ฆฌ ํ™•์žฅ ์˜์—ญ + +### Chat + +- Codex Live +- ์—๋Ÿฌ ๋กœ๊ทธ + +`Codex Live`๋Š” ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ™˜๊ฒฝ์˜ `main_project`๋ฅผ ๊ธฐ์ค€ ์ €์žฅ์†Œ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์†Œ์Šค ์ˆ˜์ •์ด ํ•„์š”ํ•˜๋ฉด **ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ๋กœ์ปฌ `main` ์ž‘์—…๋ณธ์„ ๋ฐ”๋กœ ์ˆ˜์ •**ํ•ฉ๋‹ˆ๋‹ค. + +์ผ๋ฐ˜ ์ฑ„ํŒ… ์š”์ฒญ๊ณผ ์ž‘์—…๋ฉ”๋ชจ ๋ฐ˜์˜ ์š”์ฒญ๋„ ๊ฐ™์€ ๊ธฐ์ค€์„ ๋”ฐ๋ฅด๋ฉฐ, ๋ณ„๋„ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ ์—†์ด ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์—์„œ ๋ฐ”๋กœ ์ˆ˜์ •ํ•˜๋Š” ๊ฒƒ์„ ๊ธฐ๋ณธ ๋™์ž‘์œผ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +์ฑ„ํŒ…์—์„œ ์ œ๊ณต๋˜๋Š” ํŒŒ์ผ/๋ฌธ์„œ/์ด๋ฏธ์ง€/์ฝ”๋“œ ๋ฆฌ์†Œ์Šค์™€ ์ฒจ๋ถ€ ํŒŒ์ผ์€ ์„ธ์…˜๋ณ„๋กœ `public/.codex_chat//resource/...` ์•„๋ž˜์— ๋…ธ์ถœ๋ฉ๋‹ˆ๋‹ค. + +### Play + +- Layout Editor +- ์ €์žฅ๋œ ๋ ˆ์ด์•„์›ƒ ๊ธฐ๋ก + +## Plan ๊ธฐ๋Šฅ ๊ตฌ์กฐ + +Plan ๊ด€๋ จ ์ฝ”๋“œ๋Š” `src/features/planBoard`์— ์ง‘์ค‘๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. + +- `PlanBoardPage.tsx`: ์ž๋™ํ™” ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ํŽธ์ง‘ +- `ReleaseReviewPage.tsx`: release ๊ฒ€์ˆ˜ +- `PlanSchedulePage.tsx`: ๋ฐ˜๋ณต ๋“ฑ๋ก ์Šค์ผ€์ค„ +- `charts.tsx`: ์ž‘์—… ์ถ”์ด ์ฐจํŠธ +- `api.ts`: API ํ†ต์‹  +- `types.ts`: ์ƒํƒœ/ํƒ€์ž… ์ •์˜ + +## ๋ฌธ์„œ ๊ตฌ์กฐ + +- `docs/worklogs`: ๋‚ ์งœ๋ณ„ ์ž‘์—… ๊ธฐ๋ก +- `docs/features`: ๊ธฐ๋Šฅ ์„ค๋ช…๊ณผ ์šด์˜ ๊ฐ€์ด๋“œ +- `docs/components`: ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์„ค๋ช… +- `docs/templates`: ๊ธฐ๋Šฅ/์ž‘์—…์ผ์ง€ ํ…œํ”Œ๋ฆฟ + +ํ˜„์žฌ `docs/features`์˜ ํ•ต์‹ฌ ๋ฌธ์„œ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค. + +- `project-setup.md` +- `search-layer.md` +- `plan-board-review.md` +- `plan-automation.md` +- `plan-schedule.md` +- `plan-usage.md` + +## ๊ฒ€์ƒ‰/๋ฌธ์„œ ์—ฐ๊ณ„ + +- ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ์˜ต์…˜์€ `src/app/main/mainView/searchOptions.ts`์—์„œ ๊ตฌ์„ฑ +- ๋ฌธ์„œ, Plan ํ™”๋ฉด, ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ, ์œ„์ ฏ ์ƒ˜ํ”Œ์„ ํ•˜๋‚˜์˜ ๊ฒ€์ƒ‰ ์—”ํŠธ๋ฆฌ๋กœ ์ œ๊ณต +- ์„ ํƒ ์‹œ ํ•ด๋‹น ๋ฉ”๋‰ด์™€ ํฌ์ปค์Šค ๋Œ€์ƒ์œผ๋กœ ๋ฐ”๋กœ ์ด๋™ + +## ์šด์˜ ๋ฉ”๋ชจ + +- ๊ธฐ๋Šฅ ๋ฌธ์„œ๋Š” ๊ตฌํ˜„ ํŒŒ์ผ๋ช…๊ณผ ๋ฉ”๋‰ด๋ช…์„ ๊ทธ๋Œ€๋กœ ์จ์„œ ์ฐพ๊ธฐ ์‰ฝ๊ฒŒ ์œ ์ง€ +- `docs/features` ๋ณ€๊ฒฝ๋ถ„์ด ๋ณด์ด์ง€ ์•Š์œผ๋ฉด ํ˜„์žฌ ์„ ํƒํ•œ Docs ํด๋”๊ฐ€ `๊ธฐ๋Šฅ๋ฌธ์„œ`์ธ์ง€ ๋จผ์ € ํ™•์ธ +- Plan ๊ด€๋ จ ๋ณ€๊ฒฝ์€ ๋ฌธ์„œ์™€ ๋ผ์šฐํŒ…/๊ฒ€์ƒ‰ ์˜ต์…˜์„ ํ•จ๊ป˜ ํ™•์ธ +- ์Šค์ผ€์ค„, release ๊ฒ€์ˆ˜, ์ฐจํŠธ์ฒ˜๋Ÿผ ํ™”๋ฉด์ด ๋ถ„๋ฆฌ๋œ ๊ธฐ๋Šฅ์€ ๊ฐœ๋ณ„ ๋ฌธ์„œ๋ฅผ ์œ ์ง€ diff --git a/docs/features/search-layer.md b/docs/features/search-layer.md new file mode 100755 index 0000000..217c06f --- /dev/null +++ b/docs/features/search-layer.md @@ -0,0 +1,22 @@ +# Search Layer + +## ๋ชฉ์  + +ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ์˜ ์—ด๊ธฐ/๋‹ซ๊ธฐ์™€ ๊ฒ€์ƒ‰ ์˜ต์…˜ ๋ชฉ๋ก์„ ์ „์—ญ ๋ ˆ์ด์–ด์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. + +## ๊ตฌ์กฐ + +- `src/layer/search/context` +- `src/layer/search/hooks` +- `src/layer/search/types` + +## ์—ญํ•  ๋ถ„๋ฆฌ + +- `layer/search`: ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ๋ Œ๋”๋ง, open/close, ์˜ต์…˜ ๋“ฑ๋ก +- `layer/gesture`: ์ œ์Šค์ฒ˜ ๊ฐ์ง€์™€ ๊ฒ€์ƒ‰ ์—ด๊ธฐ ํŠธ๋ฆฌ๊ฑฐ +- `store/appStore`: ํ˜„์žฌ ํŽ˜์ด์ง€์™€ ํฌ์ปค์Šค๋œ ์ปดํฌ๋„ŒํŠธ ์ƒํƒœ ๊ด€๋ฆฌ + +## ๋ฉ”๋ชจ + +- ๊ฒ€์ƒ‰ UI๋Š” ํŽ˜์ด์ง€ ๋‚ด๋ถ€์— ์ง์ ‘ ๋‘์ง€ ์•Š๊ณ  ๋ ˆ์ด์–ด์—์„œ ๋ Œ๋”๋ง +- ํŽ˜์ด์ง€๋Š” ๊ฒ€์ƒ‰ ์˜ต์…˜๋งŒ ๊ณ„์‚ฐํ•ด์„œ ๋ ˆ์ด์–ด์— ์ „๋‹ฌ diff --git a/docs/templates/feature-template.md b/docs/templates/feature-template.md new file mode 100755 index 0000000..45768a9 --- /dev/null +++ b/docs/templates/feature-template.md @@ -0,0 +1,25 @@ +# ๊ธฐ๋Šฅ๋ช… + +## ๋ชฉ์  + +- + +## ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค + +- + +## ํ™”๋ฉด/๋™์ž‘ ์„ค๋ช… + +- + +## ๋ฐ์ดํ„ฐ ๋ฐ API + +- + +## ์˜ˆ์™ธ ์ฒ˜๋ฆฌ + +- + +## ํ…Œ์ŠคํŠธ ํฌ์ธํŠธ + +- diff --git a/docs/templates/worklog-template.md b/docs/templates/worklog-template.md new file mode 100755 index 0000000..9f6a64a --- /dev/null +++ b/docs/templates/worklog-template.md @@ -0,0 +1,53 @@ +# YYYY-MM-DD ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- +- ์ด ์„น์…˜์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก, ๊ฒฝ๋กœ ๋‚˜์—ด, raw diff๋ฅผ ์ง์ ‘ ํ’€์–ด์“ฐ์ง€ ๋ง๊ณ  ์ž‘์—… ํ๋ฆ„๊ณผ ํŒ๋‹จ๋งŒ ์ •๋ฆฌ +- ํŒŒ์ผ ๋ชฉ๋ก์€ `## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ`, raw diff๋Š” `## ์†Œ์Šค`์—์„œ๋งŒ ๊ธฐ๋ก + +## ์Šคํฌ๋ฆฐ์ƒท + +- ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท 1์žฅ์€ ํ•„์ˆ˜ +- ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ ํ•„์š”ํ•œ ๋งŒํผ ์ถ”๊ฐ€ +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท์ด ์—†์œผ๋ฉด ์ž‘์—… ์ข…๋ฃŒ ์ „ ๋ฐ˜๋“œ์‹œ ์ฑ„์›€ + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `path/to/file.tsx` + +- ๋ณ€๊ฒฝ ๋˜๋Š” ์‹ ๊ทœ ์ถ”๊ฐ€ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ +- `์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ`์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก์ด๋‚˜ raw diff๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ +- `์†Œ์Šค` ํƒญ์—์„œ Codex preview ์Šคํƒ€์ผ์˜ `์ „์ฒด์†Œ์Šค / raw diff` ์ „ํ™˜์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์—๋Š” ํŒŒ์ผ๋ณ„ raw diff ์œ„์ฃผ๋กœ ๋‚จ๊น€ + +```diff +# ์ด ํŒŒ์ผ์˜ raw diff +- before ++ after +``` + +### ํŒŒ์ผ 2: `path/to/another-file.ts` + +- ํ•„์š” ์—†์œผ๋ฉด ์ด ์„น์…˜์€ ์‚ญ์ œ + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ + +- M path/to/file.tsx +- A path/to/new-file.ts diff --git a/docs/test001.md b/docs/test001.md new file mode 100755 index 0000000..3f0ca1f --- /dev/null +++ b/docs/test001.md @@ -0,0 +1 @@ +ํ…Œ์ŠคํŠธMD์ž๋™ ์ƒ์„ฑ ์ž…๋‹ˆ๋‹ค. diff --git a/docs/worklogs/2026-03-30.md b/docs/worklogs/2026-03-30.md new file mode 100755 index 0000000..b72f30f --- /dev/null +++ b/docs/worklogs/2026-03-30.md @@ -0,0 +1,126 @@ +# 2026-03-30 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- Ant Design ๊ธฐ๋ฐ˜ ํ”„๋ก ํŠธ์—”๋“œ ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐ ๊ตฌ์กฐ ์ƒ์„ฑ +- `docs/worklogs`, `docs/features` ๋ฌธ์„œ ํด๋” ๊ตฌ์„ฑ +- ๊ธฐ๋ณธ ๋Œ€์‹œ๋ณด๋“œ ์Šคํƒ€์ผ์˜ ์‹œ์ž‘ ํ™”๋ฉด ์ž‘์„ฑ +- React 19.2.4, Vite 8.0.3 ์ตœ์‹  ์•ˆ์ • ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธ +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ ๊ตฌ์กฐ๋ฅผ `plugins/`, `samples/`, `types/`, `xxxUI.tsx` ํ˜•ํƒœ๋กœ ์ •๋ฆฌ +- ๊ณตํ†ต ํ”Œ๋Ÿฌ๊ทธ์ธ ์ œ๋„ค๋ฆญ๊ณผ `plugins(props, pluginList)` ํ•ฉ์„ฑ ์œ ํ‹ธ ์ถ”๊ฐ€ +- `status-badge` ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ๊ณผ ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ ์ž‘์„ฑ +- `InputUI.tsx` ๋‹จ์ผ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ์™€ validation plugin ๊ธฐ๋ฐ˜ ์ž…๋ ฅ ์ƒ˜ํ”Œ ์ถ”๊ฐ€ +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ๊ณผ ์œ„์ ฏ ์ƒ˜ํ”Œ์„ ๋ณ„๋„ registry๋กœ ๋ถ„๋ฆฌ +- `WidgetShell`, `ApiSampleCardWidget`, ์œ„์ ฏ ์ƒ˜ํ”Œ ๋ ˆ์ด์•„์›ƒ ์ถ”๊ฐ€ +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ ๋ ˆ์ด์•„์›ƒ์„ ์ขŒ์ธก ๋‚ด๋น„๊ฒŒ์ด์…˜ + ์šฐ์ธก ์ƒ์„ธ ์นด๋“œ ๊ตฌ์กฐ๋กœ ๊ฐœํŽธ +- ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ๋ฅผ `primitives / specialized / composite` ๊ณ„์ธต์œผ๋กœ ์žฌ์ •๋ฆฌ +- Git ์ €์žฅ์†Œ ์ดˆ๊ธฐํ™”, `main` ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ, ์›๊ฒฉ `origin` ์—ฐ๊ฒฐ, ์ดˆ๊ธฐ ์ปค๋ฐ‹ ์ƒ์„ฑ + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- ์ดˆ๊ธฐ ์Šคํƒ์€ `React + Vite + TypeScript + Ant Design`์œผ๋กœ ๊ฒฐ์ • +- `antd` ํฌํ•จ ๋ฒˆ๋“ค ํŠน์„ฑ์ƒ ๋นŒ๋“œ ์‹œ ์ฒญํฌ ํฌ๊ธฐ ๊ฒฝ๊ณ ๊ฐ€ ๋ฐœ์ƒํ•จ +- ์›๊ฒฉ ์ €์žฅ์†Œ `push`๋Š” HTTPS ์ธ์ฆ ์ •๋ณด ๋ถ€์žฌ๋กœ ์‹คํŒจ +- ์ƒ˜ํ”Œ ๋ชฉ๋ก์€ `samples/Sample.tsx` ๊ธฐ๋ณธํ˜•๊ณผ `samples/*.tsx` ํ™•์žฅํ˜•์„ ๊ตฌ๋ถ„ํ•ด ๊ด€๋ฆฌํ•˜๊ธฐ๋กœ ํ•จ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ์ž‘์—…์ผ์ง€๋Š” ๋‚ ์งœ๋ณ„ Markdown ํŒŒ์ผ๋กœ ๊ด€๋ฆฌ +- ๊ธฐ๋Šฅ ๋ฌธ์„œ๋Š” ๊ธฐ๋Šฅ ๋‹จ์œ„ Markdown ํŒŒ์ผ๋กœ ๊ด€๋ฆฌ +- ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ๋Š” `docs/components` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- ์ปดํฌ๋„ŒํŠธ ์ƒ˜ํ”Œ์€ `samples/Sample.tsx`๋ฅผ ๋Œ€ํ‘œ ์ƒ˜ํ”Œ๋กœ ์‚ฌ์šฉ +- ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋Š” `InputUI.tsx` ํ•˜๋‚˜๋งŒ ๋‘๊ณ  ๊ธฐ๋Šฅ์€ plugin์œผ๋กœ ํ™•์žฅ +- ์ž…๋ ฅ ํŒจํ‚ค์ง€๋Š” `primitives`, `specialized`, `composite` ์—ญํ•  ๊ธฐ์ค€์œผ๋กœ ๋ถ„๋ฆฌ +- ํ”Œ๋Ÿฌ๊ทธ์ธ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ ๊ณตํ†ต ์ œ๋„ค๋ฆญ ํƒ€์ž…์„ ์‚ฌ์šฉํ•˜๊ณ , ํ•„์š” ์‹œ ์ปค๋ง/ํŒฉํ† ๋ฆฌ ํ˜•ํƒœ๋กœ ํ™•์žฅ +- ์—ฌ๋Ÿฌ props ํ›„์ฒ˜๋ฆฌ ํ”Œ๋Ÿฌ๊ทธ์ธ์€ `plugins(props, pluginList)` ํ˜•ํƒœ๋กœ ํ•ฉ์„ฑ +- ์ƒ˜ํ”Œ ๋ชฉ๋ก์€ ๊ฐ™์€ ์ปดํฌ๋„ŒํŠธ ๊ธฐ์ค€์œผ๋กœ ๋ฌถ๊ณ  `base -> plugin -> feature` ์ˆœ์„œ๋กœ ์ •๋ ฌ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ํ”„๋กœ์ ํŠธ ๋ถ€ํŠธ์ŠคํŠธ๋žฉ ์ดํ›„ ๋ฌธ์„œ ํด๋”์™€ ์ƒ˜ํ”Œ ๋ ˆ์ง€์ŠคํŠธ๋ฆฌ๋ฅผ ํ•จ๊ป˜ ์ •๋ฆฌํ•ด ์ดํ›„ ํ™•์žฅ ๊ธฐ์ค€์„ ๋จผ์ € ๋งˆ๋ จ +- `status-badge`, `InputUI`๋ฅผ ๊ธฐ์ค€ ์ปดํฌ๋„ŒํŠธ๋กœ ์‚ผ์•„ plugin ํ•ฉ์„ฑ ๊ตฌ์กฐ์™€ ์ƒ˜ํ”Œ ํ‘œ์‹œ ๊ตฌ์กฐ๋ฅผ ๋™์‹œ์— ๊ฒ€์ฆ +- ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ์ƒ˜ํ”Œ์„ ๋ถ„๋ฆฌํ•ด API ์„ฑ๊ฒฉ์˜ ๋ฌธ์„œ ํ™”๋ฉด์œผ๋กœ ํ™•์žฅํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋ณธ ํ˜•ํƒœ๋ฅผ ์ค€๋น„ +- ์›๊ฒฉ ์ €์žฅ์†Œ ์—ฐ๊ฒฐ๊นŒ์ง€ ์™„๋ฃŒํ–ˆ์ง€๋งŒ ์ธ์ฆ ์ด์Šˆ๋กœ `push`๋Š” ๋ณด๋ฅ˜ ์ƒํƒœ๋กœ ๋‚จ์Œ + +## ์Šคํฌ๋ฆฐ์ƒท + +![input](../assets/worklogs/2026-03-30/input.png) +![status-badge](../assets/worklogs/2026-03-30/status-badge.png) + +## ์†Œ์Šค + +- `src/components/status-badge/StatusBadgeUI.tsx`, `src/components/status-badge/plugins/status-badge.plugin.ts`: ์ƒํƒœ ๋ฐฐ์ง€ ๊ธฐ๋ณธ UI์™€ ํ”Œ๋Ÿฌ๊ทธ์ธ ํ•ฉ์„ฑ ๊ตฌ์กฐ๋ฅผ ์žก์•„ ์ƒ˜ํ”Œ ๊ฐค๋Ÿฌ๋ฆฌ์˜ ๊ธฐ์ค€ ์ปดํฌ๋„ŒํŠธ๋กœ ์‚ผ์•˜์Šต๋‹ˆ๋‹ค. +- `src/components/inputs/input/InputUI.tsx`: validation plugin ๊ธฐ๋ฐ˜ ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ์ดˆ์•ˆ์„ ์žก์•„ ์ดํ›„ `primitives / specialized / composite` ํ™•์žฅ์˜ ์ถœ๋ฐœ์ ์œผ๋กœ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/widgets/core/WidgetShell.tsx`: ์œ„์ ฏ ๋ณธ๋ฌธ์„ ๋‹จ์ˆœ ๋ฌธ๋‹จ์ด ์•„๋‹ˆ๋ผ ์ž์œ  ๋ ˆ์ด์•„์›ƒ ์ปจํ…Œ์ด๋„ˆ๋กœ ๋ฐ”๊ฟ” ์นด๋“œํ˜• ์ƒ˜ํ”Œ๊ณผ ๋Œ€์‹œ๋ณด๋“œ ์œ„์ ฏ์„ ์ˆ˜์šฉํ•˜๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `docs/templates/worklog-template.md`: ์ž‘์—…์ผ์ง€ ํ…œํ”Œ๋ฆฟ์— ์ƒ์„ธ ๋‚ด์—ญ, ์Šคํฌ๋ฆฐ์ƒท, ์†Œ์Šค, ์‹คํ–‰ ์ปค๋งจ๋“œ, ๋ณ€๊ฒฝ ํŒŒ์ผ ์„น์…˜์„ ์ถ”๊ฐ€ํ•ด ์ฆ์  ๊ธฐ๋ก ํ˜•์‹์„ ๊ณ ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/widgets/core/WidgetShell.tsx b/src/widgets/core/WidgetShell.tsx +-const { Paragraph, Title } = Typography; ++const { Title } = Typography; +... +- {children} ++
{children}
+ +diff --git a/docs/templates/worklog-template.md b/docs/templates/worklog-template.md ++## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ ++## ์Šคํฌ๋ฆฐ์ƒท ++## ์†Œ์Šค ++ + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- A .gitignore +- A README.md +- A docs/README.md +- A docs/components/status-badge.md +- A docs/features/project-setup.md +- A docs/templates/feature-template.md +- A docs/templates/worklog-template.md +- A docs/worklogs/2026-03-30.md +- A index.html +- A package-lock.json +- A package.json +- A src/App.tsx +- A src/components/status-badge/StatusBadgeUI.tsx +- A src/components/status-badge/index.ts +- A src/components/status-badge/plugins/index.ts +- A src/components/status-badge/plugins/status-badge.plugin.ts +- A src/components/status-badge/samples/Sample.tsx +- A src/components/status-badge/types/index.ts +- A src/components/status-badge/types/status-badge.ts +- A src/main.tsx +- A src/styles.css +- A src/types/component-plugin.ts +- A tsconfig.app.json +- A tsconfig.json +- A tsconfig.node.json +- A vite.config.ts + +## ์‹คํ–‰ ์ปค๋งจ๋“œ ++## ๋ณ€๊ฒฝ ํŒŒ์ผ +``` + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npm install +npm run build +git init +git checkout -b main +git add . +git commit -m "feat: initialize antd app and component plugin structure" +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `README.md` +- `docs/worklogs/2026-03-30.md` +- `src/App.tsx` +- `src/components/status-badge/StatusBadgeUI.tsx` +- `src/components/status-badge/plugins/status-badge.plugin.ts` +- `src/components/inputs/input/InputUI.tsx` +- `src/widgets/core/WidgetShell.tsx` +- `src/styles.css` +- `package.json` +- `vite.config.ts` diff --git a/docs/worklogs/2026-03-31.md b/docs/worklogs/2026-03-31.md new file mode 100755 index 0000000..37bdce3 --- /dev/null +++ b/docs/worklogs/2026-03-31.md @@ -0,0 +1,173 @@ +# 2026-03-31 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ๋ฅผ `primitives / specialized / composite` ๊ณ„์ธต์œผ๋กœ ์žฌ์ •๋ฆฌ +- ๊ธฐ๋ณธ ์ž…๋ ฅ์„ `src/components/inputs/primitives/input` ์•„๋ž˜๋กœ ์ด๋™ +- ์ด๋ฉ”์ผ ์ž…๋ ฅ์„ `src/components/inputs/specialized/emailInput` ๊ตฌ์กฐ๋กœ ์ •๋ฆฌ +- 3๋ถ„ํ•  ์ž…๋ ฅ์„ `src/components/inputs/composite/multiInput` ๊ตฌ์กฐ๋กœ ์ •๋ฆฌ +- ์ƒ˜ํ”Œ ๋ฐ ๋ฌธ์„œ ๊ฒฝ๋กœ๋ฅผ ์ƒˆ ์ž…๋ ฅ ๊ณ„์ธต ๊ตฌ์กฐ์— ๋งž๊ฒŒ ๊ฐฑ์‹  +- ํ”„๋กœ์ ํŠธ ์ข…์† ๋ ˆ์ด์•„์›ƒ์„ `src/features/layout` ์•„๋ž˜๋กœ ์ด๋™ +- `basePath` ๊ธฐ๋ฐ˜ ๊ณตํ†ต markdown preview ์ปดํฌ๋„ŒํŠธ์™€ ๋ฆฌ์ŠคํŠธ ๊ตฌ์„ฑ ์ถ”๊ฐ€ +- `docs/**/*.md`๊นŒ์ง€ ์ฝ์„ ์ˆ˜ ์žˆ๋„๋ก markdown registry ๋ฒ”์œ„ ํ™•์žฅ +- `docs` ๋ฌธ์„œ๋ฅผ ์ขŒ์ธก ํด๋”/๋ฌธ์„œ ํŠธ๋ฆฌ + ์šฐ์ธก markdown ์นด๋“œ ๋ชฉ๋ก ๊ตฌ์กฐ๋กœ ๊ตฌ์„ฑ +- ์ขŒ์ธก ๋ฌธ์„œ ๋ฐ”๋กœ๊ฐ€๊ธฐ๋ฅผ ๊ณตํ†ต `FolderTreeNav` ๊ธฐ๋ฐ˜ ์ ‘๊ธฐ/ํŽผ์น˜๊ธฐ ํŠธ๋ฆฌ๋กœ ์ •๋ฆฌ +- ๊ตฌ์กฐ ๋ณ€๊ฒฝ ๋‚ด์šฉ์„ ์ปค๋ฐ‹ํ•˜๊ณ  ์›๊ฒฉ `main` ๋ธŒ๋žœ์น˜๋กœ ํ‘ธ์‹œ + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ์ด๋ฆ„๊ณผ ํŒจํ‚ค์ง€๋ช… ๊ธฐ์ค€์ด ์ค‘๊ฐ„์— ๋ช‡ ์ฐจ๋ก€ ์กฐ์ •๋จ +- ์ƒ˜ํ”Œ ๋ ˆ์ด์•„์›ƒ๊ณผ ๋ฌธ์„œ ๊ฒฝ๋กœ๋Š” ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ ํ•จ๊ป˜ ๊ด€๋ฆฌํ•ด์•ผ ํ•จ +- `src` ๊ธฐ์ค€ glob๋งŒ ์‚ฌ์šฉํ•˜๋ฉด ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ `docs/`๋Š” ์ฝํžˆ์ง€ ์•Š์Œ +- ๋นŒ๋“œ๋Š” ์ •์ƒ ํ†ต๊ณผํ–ˆ์ง€๋งŒ `antd` ํฌํ•จ ๋ฒˆ๋“ค๋กœ ์ธํ•ด ์ฒญํฌ ํฌ๊ธฐ ๊ฒฝ๊ณ ๋Š” ๊ณ„์† ๋ฐœ์ƒ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋Š” ์—ญํ•  ๊ธฐ์ค€์œผ๋กœ `primitives`, `specialized`, `composite`๋กœ ๋ถ„๋ฆฌ +- `InputUI`๋Š” primitive๋กœ ๊ด€๋ฆฌ +- `EmailInputUI`๋Š” specialized ์ž…๋ ฅ์œผ๋กœ ๊ด€๋ฆฌ +- `MultiInputUI`๋Š” ์—ฌ๋Ÿฌ ์ž…๋ ฅ์„ ์กฐํ•ฉํ•œ composite ์ž…๋ ฅ์œผ๋กœ ๊ด€๋ฆฌ +- ํ”„๋กœ์ ํŠธ ์ข…์†์ ์ธ ๋ ˆ์ด์•„์›ƒ์€ `src/features/layout` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- markdown preview๋Š” ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๊ฐ€ `basePath`๋ฅผ ๋ฐ›์•„ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ +- ์ž‘์—…์ผ์ง€๋Š” ๋‚ ์งœ๋ณ„ ํŒŒ์ผ๋กœ ๊ณ„์† ๋ˆ„์  ๊ธฐ๋ก + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ์ž…๋ ฅ ๊ณ„์ธต ์žฌ์ •๋ฆฌ์™€ ๋™์‹œ์— ์ƒ˜ํ”Œ/๋ฌธ์„œ ๊ฒฝ๋กœ๋„ ๊ฐ™์ด ์˜ฎ๊ฒจ ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ ๋ˆ„๋ฝ ํฌ์ธํŠธ๋ฅผ ์ค„์ž„ +- `docs` ํŠธ๋ฆฌ์™€ markdown preview๋ฅผ ๊ณตํ†ตํ™”ํ•˜๋ฉด์„œ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ ๋ฌธ์„œ๋„ ์ฝ์„ ์ˆ˜ ์žˆ๊ฒŒ ๋ฒ”์œ„๋ฅผ ํ™•์žฅ +- ์ขŒ์ธก ํด๋” ํŠธ๋ฆฌ์™€ ์šฐ์ธก ์นด๋“œ ๋ชฉ๋ก ๊ตฌ์„ฑ์„ ๋„์ž…ํ•ด ๋ฌธ์„œ ํƒ์ƒ‰ ํ™”๋ฉด์˜ ๊ธฐ๋ณธ ํ‹€์„ ์™„์„ฑ +- ๊ตฌ์กฐ ๊ฐœํŽธ ํ›„ ๋นŒ๋“œ์™€ ์›๊ฒฉ ํ‘ธ์‹œ๊นŒ์ง€ ๋งˆ์ณ ๋ฌธ์„œ ๊ธฐ๋ฐ˜ ํ™”๋ฉด์„ ๊ธฐ์ค€์„ ์œผ๋กœ ๊ณ ์ • + +## ์Šคํฌ๋ฆฐ์ƒท + +![email-input](../assets/worklogs/2026-03-31/email-input.png) +![multi-input](../assets/worklogs/2026-03-31/multi-input.png) + +## ์†Œ์Šค + +- `src/components/inputs/primitives/input/InputUI.tsx`: ๊ธฐ๋ณธ ์ž…๋ ฅ์„ primitive ๊ณ„์ธต์œผ๋กœ ๊ณ ์ •ํ•˜๊ณ  commit plugin ๊ฒ€์ฆ ํ๋ฆ„์„ ๊ณตํ†ต ์—”ํŠธ๋ฆฌ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/inputs/specialized/emailInput/EmailInputUI.tsx`: ์ด๋ฉ”์ผ ์ „์šฉ ์ž…๋ ฅ์„ primitive ์œ„์— ์–น์–ด specialized ๊ณ„์ธต ๊ทœ์น™์„ ๊ฒ€์ฆํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/inputs/composite/multiInput/MultiInputUI.tsx`: ์ „ํ™”๋ฒˆํ˜ธํ˜• 3๋ถ„ํ•  ์ž…๋ ฅ์„ ์ถ”๊ฐ€ํ•ด composite ๊ณ„์ธต๊ณผ ์„ธ๊ทธ๋จผํŠธ ์ด๋™ UX๋ฅผ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/markdownPreview/MarkdownPreviewCard.tsx`, `src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx`: ๋ฃจํŠธ `docs/` ๋ฌธ์„œ๋ฅผ ์นด๋“œ์™€ ํด๋” ํŠธ๋ฆฌ๋กœ ํƒ์ƒ‰ํ•˜๋Š” Docs ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๋ ˆ์ด์•„์›ƒ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/components/inputs/composite/multiInput/MultiInputUI.tsx b/src/components/inputs/composite/multiInput/MultiInputUI.tsx ++function splitValue(value?: string): MultiInputParts { ++ const digits = (value ?? '').replace(/\D/g, '').slice(0, 11); ++ return [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7, 11)]; ++} +... ++ if (event.target.value.length === 3) { ++ secondRef.current?.focus(); ++ } + +diff --git a/src/components/inputs/specialized/emailInput/EmailInputUI.tsx b/src/components/inputs/specialized/emailInput/EmailInputUI.tsx ++ inputMode={inputMode} ++ placeholder={placeholder} ++ autoComplete={autoComplete} ++ commitPlugins={[createEmailValidatorPlugin(), ...commitPlugins]} +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- M README.md +- M docs/README.md +- M docs/features/project-setup.md +- A docs/worklogs/2026-03-31.md +- M src/App.tsx +- A src/components/markdownPreview/MarkdownPreviewCard.tsx +- A src/components/markdownPreview/MarkdownPreviewContent.tsx +- A src/components/markdownPreview/MarkdownPreviewList.tsx +- A src/components/markdownPreview/index.ts +- A src/components/markdownPreview/markdown-document.ts +- A src/components/markdownPreview/registry.ts +- A src/components/navigation/folder-tree-nav.tsx +- A src/components/navigation/index.ts +- A src/features/layout/README.md +- R src/layouts/component-sample-gallery/ComponentSamplesLayout.tsx -> src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx +- R src/layouts/component-sample-gallery/index.ts -> src/features/layout/component-sample-gallery/index.ts +- A src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx +- A src/features/layout/docs-markdown-preview/index.ts +- A src/features/layout/feature-markdown-preview/FeatureMarkdownPreviewListLayout.tsx +- A src/features/layout/feature-markdown-preview/index.ts +- R src/layouts/widget-registry-gallery/WidgetRegistryLayout.tsx -> src/features/layout/widget-registry-gallery/WidgetRegistryLayout.tsx +- R src/layouts/widget-registry-gallery/index.ts -> src/features/layout/widget-registry-gallery/index.ts +- R src/layouts/widget-sample-gallery/SampleWidgetsLayout.tsx -> src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx +- R src/layouts/widget-sample-gallery/index.ts -> src/features/layout/widget-sample-gallery/index.ts +- A src/features/markdownPreview/FeatureMarkdownPreviewCard.tsx +- A src/features/markdownPreview/index.ts +- A src/features/overview.md +- M src/styles.css +- M docs/components/input.md +- M docs/worklogs/2026-03-30.md +- A src/components/inputs/composite/multiInput/MultiInputUI.tsx +- A src/components/inputs/composite/multiInput/index.ts +- A src/components/inputs/composite/multiInput/plugins/index.ts +- A src/components/inputs/composite/multiInput/plugins/multi-input.plugin.ts +- A src/components/inputs/composite/multiInput/samples/Sample.tsx +- A src/components/inputs/composite/multiInput/types/index.ts +- A src/components/inputs/composite/multiInput/types/multi-input.ts +- R src/components/inputs/input/InputUI.tsx -> src/components/inputs/primitives/input/InputUI.tsx +- R src/components/inputs/input/index.ts -> src/components/inputs/primitives/input/index.ts +- R src/components/inputs/input/plugins/index.ts -> src/components/inputs/primitives/input/plugins/index.ts +- R src/components/inputs/input/plugins/input.plugin.ts -> src/components/inputs/primitives/input/plugins/input.plugin.ts +- R src/components/inputs/input/samples/Sample.tsx -> src/components/inputs/primitives/input/samples/Sample.tsx +- R src/components/inputs/input/samples/ValidInputSample.tsx -> src/components/inputs/primitives/input/samples/ValidInputSample.tsx +- R src/components/inputs/input/types/index.ts -> src/components/inputs/primitives/input/types/index.ts +- R src/components/inputs/input/types/input.ts -> src/components/inputs/primitives/input/types/input.ts +- A src/components/inputs/specialized/emailInput/EmailInputUI.tsx +- A src/components/inputs/specialized/emailInput/index.ts +- A src/components/inputs/specialized/emailInput/plugins/email-input.plugin.ts +- A src/components/inputs/specialized/emailInput/plugins/index.ts +- A src/components/inputs/specialized/emailInput/samples/Sample.tsx +- A src/components/inputs/specialized/emailInput/types/email-input.ts +- A src/components/inputs/specialized/emailInput/types/index.ts +- A docs/components/input.md +- A src/components/inputs/input/InputUI.tsx +- A src/components/inputs/input/index.ts +- A src/components/inputs/input/plugins/index.ts +- A src/components/inputs/input/plugins/input.plugin.ts +- A src/components/inputs/input/samples/Sample.tsx +- A src/components/inputs/input/samples/ValidInputSample.tsx +- A src/components/inputs/input/types/index.ts +- A src/components/inputs/input/types/input.ts +- M src/components/status-badge/samples/Sample.tsx +- A src/layouts/component-sample-gallery/ComponentSamplesLayout.tsx +- A src/layouts/component-sample-gallery/index.ts +- A src/layouts/widget-registry-gallery/WidgetRegistryLayout.tsx +- A src/layouts/widget-registry-gallery/index.ts +- A src/layouts/widget-sample-gallery/SampleWidgetsLayout.tsx +- A src/layouts/widget-sample-gallery/index.ts +- A src/samples/registry.ts +- A src/vite-env.d.ts +- A src/widgets/api-sample-card/ApiSampleCardWidget.tsx +- A src/widgets/api-sample-card/index.ts +- A src/widgets/api-sample-card/samples/Sample.tsx +- A src/widgets/core/WidgetShell.tsx +- A src/widgets/core/index.ts +- A src/widgets/core/registry/widget-features.ts +- A src/widgets/core/types/widget.ts +- A src/widgets/registry.ts + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npm run build +git add . +git commit -m "์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ ์ •๋ฆฌ" +git commit -m "๋ฌธ์„œ ๋ฏธ๋ฆฌ๋ณด๊ธฐ์™€ ๋ ˆ์ด์•„์›ƒ ์ •๋ฆฌ" +git push origin main +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `docs/worklogs/2026-03-31.md` +- `src/components/inputs/primitives/input/InputUI.tsx` +- `src/components/inputs/specialized/emailInput/EmailInputUI.tsx` +- `src/components/inputs/composite/multiInput/MultiInputUI.tsx` +- `src/components/markdownPreview/MarkdownPreviewCard.tsx` +- `src/components/markdownPreview/MarkdownPreviewContent.tsx` +- `src/components/markdownPreview/MarkdownPreviewList.tsx` +- `src/components/navigation/folder-tree-nav.tsx` +- `src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx` +- `src/styles.css` diff --git a/docs/worklogs/2026-04-01.md b/docs/worklogs/2026-04-01.md new file mode 100755 index 0000000..32003bd --- /dev/null +++ b/docs/worklogs/2026-04-01.md @@ -0,0 +1,254 @@ +# 2026-04-01 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ๋Œ€์‹œ๋ณด๋“œ ์นด๋“œ ์œ„์ ฏ๊ณผ ์ƒ˜ํ”Œ ๊ตฌ์„ฑ์„ ๋ณด๊ฐ• +- WMS/TMS ๋Œ€์‹œ๋ณด๋“œ ์ฝ˜ํ…์ธ  ๋ฐฐ์น˜์™€ ์ฐจํŠธ ํ‘œํ˜„์„ ์กฐ์ • +- ๋Œ€์‹œ๋ณด๋“œ ๊ณตํ†ต ํ‘œํ˜„ ์ปดํฌ๋„ŒํŠธ `progress`, `multiProgress` ๊ตฌ์กฐ๋ฅผ ์ •๋ฆฌ +- `src/data` ์•„๋ž˜๋กœ ๋Œ€์‹œ๋ณด๋“œ ํ”„๋ฆฌ์…‹ ๋ฐ์ดํ„ฐ๋ฅผ ๋ถ„๋ฆฌ +- ๋ฉ”์ธ ํ™”๋ฉด์„ ์ขŒ์ธก ๋ฉ”๋‰ด ๊ธฐ๋ฐ˜ API ์Šคํƒ€์ผ ๊ตฌ์กฐ๋กœ ์ •๋ฆฌ +- `popup` ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  UI ์Šคํƒ€์ผ์„ ๋ณด๊ฐ• +- `select` ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- `checkCombo` ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- `package.json`์— Nexus `publishConfig` ์ถ”๊ฐ€ +- ๋ฒ„์ „์„ `0.1.0-alpha.0`๋กœ ์กฐ์ •ํ•˜๊ณ  publish์šฉ `private` ์„ค์ •์„ ํ•ด์ œ +- ๋ฉ”์ธ ํ™”๋ฉด์„ `src/views/main` ์•„๋ž˜๋กœ ์ž„์‹œ ๋ถ„๋ฆฌ +- Nexus ์ธ์ฆ ๋ฐฉ์‹์„ `username / _password(base64)` ํ˜•์‹์œผ๋กœ ์žฌ๊ตฌ์„ฑ +- Nexus์— `ai-code-app@0.1.0-alpha.0` ๋ฐฐํฌ ์„ฑ๊ณต +- ๋ฉ”์ธ ํ™”๋ฉด ๊ตฌ์กฐ๋ฅผ `src/app/main` ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ์ •๋ฆฌํ•˜๊ณ  export ๊ฒฝ๋กœ๋ฅผ ๋ณด๊ฐ• +- ์ฝ˜ํ…์ธ  ์˜์—ญ ๋ฐ”๊นฅ ์—ฌ๋ฐฑ์„ ์ค„์—ฌ ์นด๋“œ ์ค‘์‹ฌ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ์ •๋ฆฌ +- ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ์—๋งŒ ํŽธ์ง‘ ๊ฐ€๋Šฅํ•œ `buttonEditableInput` ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- `buttonEditableInput`์˜ readonly ์Œ์˜๊ณผ ํ™•์ธ ๋ฒ„ํŠผ ํŽธ์ง‘ ์™„๋ฃŒ ๋™์ž‘์„ ๋ณด์ • + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- `npm publish`๋Š” alpha ๋ฒ„์ „ ๋ฐฐํฌ ์‹œ `--tag alpha`๊ฐ€ ํ•„์š” +- Nexus๋Š” ํ† ํฐ ๋ฐฉ์‹๋ณด๋‹ค `username / _password(base64)` ๋ฐฉ์‹์—์„œ ์ธ์ฆ์ด ์ •์ƒ ๋™์ž‘ํ•จ +- popup ์ž…๋ ฅ์€ ์Šคํƒ€์ผ ๋ณด๊ฐ• ๊ณผ์ •์—์„œ ์ž…๋ ฅ/๋ฒ„ํŠผ/readonly ์˜์—ญ์˜ ๊ฒฝ๊ณ„์™€ ํ†ค์„ ๋ฐ˜๋ณต ์กฐ์ • +- ๋Œ€์‹œ๋ณด๋“œ ์นด๋“œ ๋†’์ด์™€ ์ฝ˜ํ…์ธ  ๊ฐ„๊ฒฉ ์กฐ์ •์€ ๊ณผ๋„ํ•œ ๊ณ ์ •๊ฐ’์„ ํ”ผํ•˜๊ณ  ์ ์ง„์ ์œผ๋กœ ์กฐ์ •ํ•˜๋Š” ํŽธ์ด ์•ˆ์ •์  +- ๋ฒ„ํŠผ ๊ธฐ๋ฐ˜ ํŽธ์ง‘ ์ž…๋ ฅ์€ blur, Enter, ๋ฒ„ํŠผ ํด๋ฆญ ํ™•์ • ํƒ€์ด๋ฐ์„ ํ•จ๊ป˜ ๋งž์ถฐ์•ผ UX๊ฐ€ ์•ˆ์ •์  + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ƒ˜ํ”Œ/ํ”„๋ฆฌ์…‹ ๋ฐ์ดํ„ฐ๋Š” `src/data`์—์„œ ๊ด€๋ฆฌ +- ๋Œ€์‹œ๋ณด๋“œ ๊ณตํ†ต ํ‘œํ˜„์€ `src/components/dashboard`, ์นด๋“œ ์กฐํ•ฉ์€ `src/widgets/dashboard-report-card`๋กœ ๋ถ„๋ฆฌ +- ์‹ ๊ทœ ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ๋„ `UI / plugins / samples / types` ๊ตฌ์กฐ๋ฅผ ๋™์ผํ•˜๊ฒŒ ์ ์šฉ +- ๋ฉ”์ธ ํ™”๋ฉด์€ ์ž„์‹œ๋กœ `views/main` ์•„๋ž˜์— `header / sidebar / content` ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌ +- ๋ฐฐํฌ๋Š” `npm publish --tag alpha` ๊ธฐ์ค€์œผ๋กœ ์ง„ํ–‰ +- ๋ฉ”์ธ ํ™”๋ฉด ์—”ํŠธ๋ฆฌ๋Š” `src/app/main` ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌํ•˜๊ณ  ํŒจํ‚ค์ง€ ๋ฃจํŠธ export๋ฅผ ํ•จ๊ป˜ ๊ด€๋ฆฌ +- ๋ฒ„ํŠผ ํŽธ์ง‘ ์ž…๋ ฅ์€ ๊ธฐ๋ณธ readonly ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•˜๊ณ  ๊ฒ€์ฆ ์‹คํŒจ ์‹œ ์ด์ „ ๊ฐ’์œผ๋กœ ๋ณต์› + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ๋Œ€์‹œ๋ณด๋“œ ์œ„์ ฏ, ์ž…๋ ฅ ์ปดํฌ๋„ŒํŠธ, ๋ฉ”์ธ ํ™”๋ฉด ๊ตฌ์กฐ ๋ณ€๊ฒฝ์ด ๋™์‹œ์— ์ง„ํ–‰๋˜๋ฉฐ ์•ฑ ์„ฑ๊ฒฉ์ด ์ƒ˜ํ”Œ ๊ฐค๋Ÿฌ๋ฆฌ ์ค‘์‹ฌ์œผ๋กœ ์ด๋™ +- Nexus ๋ฐฐํฌ ์„ค์ •๊ณผ ์ธ์ฆ ๋ฐฉ์‹์„ ์‹ค์‚ฌ์šฉ ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌํ•ด alpha ๋ฐฐํฌ ๊ฒฝ๋กœ๋ฅผ ์‹ค์ œ๋กœ ๊ฒ€์ฆ +- `popup`, `select`, `checkCombo`, `buttonEditableInput`์„ ์ถ”๊ฐ€ํ•˜๋ฉฐ ์ž…๋ ฅ UI ํŒจํ‚ค์ง€ ํ™•์žฅ ํŒจํ„ด์„ ๊ตฌ์ฒดํ™” +- ๋ฉ”์ธ ํ™”๋ฉด์„ `src/app/main`์œผ๋กœ ์žฌ์ •๋ฆฌํ•˜๋ฉด์„œ ์ดํ›„ ํ—ค๋”/์‚ฌ์ด๋“œ๋ฐ”/์ฝ˜ํ…์ธ  ๋ถ„๋ฆฌ ์ž‘์—…์˜ ๊ธฐ๋ฐ˜์„ ๋งˆ๋ จ + +## ์Šคํฌ๋ฆฐ์ƒท + +![button-editable-input](../assets/worklogs/2026-04-01/button-editable-input.png) +![check-combo-input](../assets/worklogs/2026-04-01/check-combo-input.png) +![dashboard-multi-progress](../assets/worklogs/2026-04-01/dashboard-multi-progress.png) +![dashboard-progress](../assets/worklogs/2026-04-01/dashboard-progress.png) +![main-content-fullscreen-toggle](../assets/worklogs/2026-04-01/main-content-fullscreen-toggle.png) +![popup-input](../assets/worklogs/2026-04-01/popup-input.png) +![select-input](../assets/worklogs/2026-04-01/select-input.png) + +## ์†Œ์Šค + +- `src/components/dashboard/progress/ProgressUI.tsx`, `src/components/dashboard/multiProgress/MultiProgressUI.tsx`: ๋Œ€์‹œ๋ณด๋“œ ์ง„ํ–‰๋ฅ  ํ‘œํ˜„์„ ์ปดํฌ๋„ŒํŠธํ™”ํ•ด ์นด๋“œ/์ƒ˜ํ”Œ/์œ„์ ฏ์—์„œ ๊ณตํ†ต์œผ๋กœ ์“ฐ๊ฒŒ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/inputs/popup/PopupUI.tsx`, `src/components/inputs/select/SelectUI.tsx`, `src/components/inputs/checkCombo/CheckComboUI.tsx`: ์‹ ๊ทœ ์ž…๋ ฅ๊ตฐ์„ ๊ฐ™์€ ํŒจํ‚ค์ง€ ๊ทœ์น™์œผ๋กœ ์ถ”๊ฐ€ํ•ด ์„ ํƒํ˜• ์ž…๋ ฅ ๋ฒ”์œ„๋ฅผ ๋„“ํ˜”์Šต๋‹ˆ๋‹ค. +- `src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx`: readonly ์ƒํƒœ์™€ ํŽธ์ง‘ ํ™•์ • ๋ฒ„ํŠผ์„ ๊ฐ€์ง„ ํด๋ฆญ ํŽธ์ง‘ ์ž…๋ ฅ์„ ๋„์ž…ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/app/main/MainContent.tsx`: ๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋ฅผ `Docs / APIs` ๊ธฐ์ค€ ์นด๋“œ ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ๋‚˜๋ˆ  ํ˜„์žฌ ์•ฑ ์„ฑ๊ฒฉ์— ๋งž๋Š” ๋ฉ”์ธ ํ™”๋ฉด์œผ๋กœ ์žฌ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx b/src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx ++ const [isEditing, setIsEditing] = useState(false); +... ++ readOnly={!isEditing} ++ commitPlugins={mergedCommitPlugins} +... ++ {isEditing ? confirmButtonLabel : editButtonLabel} + +diff --git a/src/app/main/MainContent.tsx b/src/app/main/MainContent.tsx ++ {activeTopMenu === 'docs' ? ( ++
++ ++ {selectedDocs.map((document) => ( ++ ++ ))} +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- A app-dist/assets/2026-03-30-C4SD1FRx.js +- A app-dist/assets/2026-03-31-DwLJWvh2.js +- A app-dist/assets/2026-04-01-D5gI7Q4h.js +- A app-dist/assets/AntdIcon-Byo_R91X.js +- A app-dist/assets/CloseOutlined-B6nrJF3-.js +- A app-dist/assets/InputUI-DAmC5DJh.js +- A app-dist/assets/MultiProgressUI--uB5kqTr.js +- A app-dist/assets/ProgressUI-C91UL-oJ.js +- A app-dist/assets/README-CI9EVrw_.js +- A app-dist/assets/README-O9_O-4tf2.js +- A app-dist/assets/Sample-6Ml90fMj.js +- A app-dist/assets/Sample-BJxnglT1.js +- A app-dist/assets/Sample-BPCdH5hH.js +- A app-dist/assets/Sample-CLup9Uwo.js +- A app-dist/assets/Sample-CeT4nPqx.js +- A app-dist/assets/Sample-DKoCtyPX.js +- A app-dist/assets/Sample-DMEGMJwT.js +- A app-dist/assets/Sample-Dyso1eHr.js +- A app-dist/assets/Sample-E6V4D3Du.js +- A app-dist/assets/Sample-LB0lRdor.js +- A app-dist/assets/Sample-xgRr-oUd.js +- A app-dist/assets/SearchOutlined-Civ7xtmP.js +- A app-dist/assets/TmsDeliveryFlowSample-BHeS93-n.js +- A app-dist/assets/TmsDeliveryMetricsSample-BQV5az65.js +- A app-dist/assets/ValidInputSample-C9pl9si5.js +- A app-dist/assets/WidgetShell-DhXCYrC8.js +- A app-dist/assets/WmsInboundOutboundSample-BZCM3_0V.js +- A app-dist/assets/WmsInventoryTrendSample-DvxPBjgx.js +- A app-dist/assets/card-BpKFEf6A.js +- A app-dist/assets/check-combo-Bz7kGmN1.js +- A app-dist/assets/clsx-CzIxj0DI.js +- A app-dist/assets/component-plugin-BjxKibxS.js +- A app-dist/assets/dashboard-report-presets-Bh8duNGL.js +- A app-dist/assets/feature-template-D3D0o1kc.js +- A app-dist/assets/index-BQsYfbAI.js +- A app-dist/assets/index-CaXbpawn.css +- A app-dist/assets/input-B6oA1SZJ.js +- A app-dist/assets/input.plugin-ulF_zEvq.js +- A app-dist/assets/jsx-runtime-CNArSbpp.js +- A app-dist/assets/overview-DgYaz2rW.js +- A app-dist/assets/popup-BGFdvx2z.js +- A app-dist/assets/project-setup-jU8Nv-E8.js +- A app-dist/assets/select-DYfkmyn8.js +- A app-dist/assets/select-kIZVYgkF.js +- A app-dist/assets/status-badge-1fx0opaz.js +- A app-dist/assets/wave-DQjt-ubw.js +- A app-dist/assets/worklog-template-DE_f72dx.js +- A app-dist/index.html +- A docker-compose.yml +- M docs/worklogs/2026-04-01.md +- M package-lock.json +- M package.json +- M src/App.tsx +- A src/app/main/MainContent.tsx +- A src/app/main/MainHeader.tsx +- R src/views/main/MainSidebar.tsx -> src/app/main/MainSidebar.tsx +- R src/views/main/MainView.tsx -> src/app/main/MainView.tsx +- R src/views/main/index.ts -> src/app/main/index.ts +- R src/views/main/types.ts -> src/app/main/types.ts +- A src/app/manifests/docs.manifest.ts +- A src/app/manifests/samples.manifest.ts +- M src/components/inputs/primitives/input/index.ts +- A src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.css +- A src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx +- A src/components/inputs/specialized/buttonEditableInput/index.ts +- A src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx +- M src/components/markdownPreview/MarkdownPreviewList.tsx +- M src/components/markdownPreview/index.ts +- M src/components/markdownPreview/registry.ts +- M src/components/status-badge/index.ts +- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx +- M src/features/layout/dashboard-report-gallery/DashboardReportGalleryLayout.tsx +- M src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx +- M src/features/layout/feature-markdown-preview/FeatureMarkdownPreviewListLayout.tsx +- M src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx +- A src/index.ts +- M src/samples/registry.ts +- M src/styles.css +- M src/views/main/MainContent.tsx +- D src/views/main/MainHeader.tsx +- A tsconfig.lib.json +- M README.md +- M docs/README.md +- M docs/features/project-setup.md +- A src/views/main/MainContent.tsx +- A src/views/main/MainHeader.tsx +- A src/views/main/MainSidebar.tsx +- A src/views/main/MainView.tsx +- A src/views/main/index.ts +- A src/views/main/types.ts +- A docs/components/check-combo.md +- A docs/components/popup.md +- A docs/components/select.md +- A docs/worklogs/2026-04-01.md +- A src/components/dashboard/multiProgress/MultiProgressUI.tsx +- A src/components/dashboard/multiProgress/index.ts +- A src/components/dashboard/multiProgress/plugins/index.ts +- A src/components/dashboard/multiProgress/plugins/multi-progress.plugin.ts +- A src/components/dashboard/multiProgress/samples/Sample.tsx +- A src/components/dashboard/multiProgress/types/index.ts +- A src/components/dashboard/multiProgress/types/multi-progress.ts +- A src/components/dashboard/progress/ProgressUI.tsx +- A src/components/dashboard/progress/index.ts +- A src/components/dashboard/progress/plugins/index.ts +- A src/components/dashboard/progress/plugins/progress.plugin.ts +- A src/components/dashboard/progress/samples/Sample.tsx +- A src/components/dashboard/progress/types/index.ts +- A src/components/dashboard/progress/types/progress.ts +- A src/components/inputs/checkCombo/CheckComboUI.tsx +- A src/components/inputs/checkCombo/index.ts +- A src/components/inputs/checkCombo/plugins/check-combo.plugin.ts +- A src/components/inputs/checkCombo/plugins/index.ts +- A src/components/inputs/checkCombo/samples/Sample.tsx +- A src/components/inputs/checkCombo/types/check-combo.ts +- A src/components/inputs/checkCombo/types/index.ts +- A src/components/inputs/popup/PopupUI.tsx +- A src/components/inputs/popup/index.ts +- A src/components/inputs/popup/plugins/index.ts +- A src/components/inputs/popup/plugins/popup.plugin.ts +- A src/components/inputs/popup/samples/Sample.tsx +- A src/components/inputs/popup/types/index.ts +- A src/components/inputs/popup/types/popup.ts +- A src/components/inputs/select/SelectUI.tsx +- A src/components/inputs/select/index.ts +- A src/components/inputs/select/plugins/index.ts +- A src/components/inputs/select/plugins/select.plugin.ts +- A src/components/inputs/select/samples/Sample.tsx +- A src/components/inputs/select/types/index.ts +- A src/components/inputs/select/types/select.ts +- A src/components/navigation/SectionMenuLayout.tsx +- M src/components/navigation/index.ts +- A src/data/dashboard-report-presets.ts +- A src/features/dashboard/TmsDashboardFeatureSamples.tsx +- A src/features/dashboard/WmsDashboardFeatureSamples.tsx +- A src/features/layout/dashboard-feature-gallery/DashboardFeatureGalleryLayout.tsx +- A src/features/layout/dashboard-feature-gallery/index.ts +- A src/features/layout/dashboard-report-gallery/DashboardReportGalleryLayout.tsx +- A src/features/layout/dashboard-report-gallery/index.ts +- M src/widgets/core/WidgetShell.tsx +- A src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx +- A src/widgets/dashboard-report-card/index.ts +- A src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx +- A src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx +- A src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx +- A src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx +- M src/widgets/registry.ts + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npm run build +npm publish --tag alpha +npm run capture:component -- --date 2026-04-01 --name check-combo-input +npm run capture:component -- --date 2026-04-01 --name select-input +npm run capture:component -- --date 2026-04-01 --name popup-input +npm run capture:fullscreen -- --date 2026-04-01 +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `docs/worklogs/2026-04-01.md` +- `package.json` +- `src/components/dashboard/progress/ProgressUI.tsx` +- `src/components/dashboard/multiProgress/MultiProgressUI.tsx` +- `src/components/inputs/popup/PopupUI.tsx` +- `src/components/inputs/select/SelectUI.tsx` +- `src/components/inputs/checkCombo/CheckComboUI.tsx` +- `src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx` +- `src/app/main/MainContent.tsx` +- `src/styles.css` diff --git a/docs/worklogs/2026-04-02.md b/docs/worklogs/2026-04-02.md new file mode 100755 index 0000000..5e0d02f --- /dev/null +++ b/docs/worklogs/2026-04-02.md @@ -0,0 +1,396 @@ +# 2026-04-02 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ๋ฃจํŠธ `README.md`๋ฅผ ํ˜„์žฌ ์•ฑ ๊ตฌ์กฐ์™€ ์Šคํฌ๋ฆฝํŠธ ๊ธฐ์ค€์œผ๋กœ ์ตœ์‹ ํ™” +- `docs/README.md`์˜ ๋ฌธ์„œ ์šด์˜ ๊ธฐ์ค€์„ ์‹ค์ œ ๋””๋ ‰ํ„ฐ๋ฆฌ ๊ตฌ์กฐ์™€ Docs ์ˆ˜์ง‘ ๋ฐฉ์‹์— ๋งž๊ฒŒ ์ •๋ฆฌ +- ์˜ค๋Š˜ ์ž‘์—…์ผ์ง€์— ๋ฌธ์„œ ์ตœ์‹ ํ™” ๋‚ด์—ญ์„ ์ถ”๊ฐ€ +- `window-ui` ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- ์œˆ๋„์šฐ ํ—ค๋” ๋“œ๋ž˜๊ทธ, ๋ฆฌ์‚ฌ์ด์ฆˆ, ์ตœ์†Œํ™”, ์ตœ๋Œ€ํ™”, ๋ณต์› ๋™์ž‘์„ ๊ตฌ์„ฑ +- ๋ชจ๋ฐ”์ผ ํ„ฐ์น˜ ๊ธฐ๋ฐ˜ ๋“œ๋ž˜๊ทธ์™€ ๋ฒ„ํŠผ ํ„ฐ์น˜ ์˜์—ญ์„ ๋ณด์ • +- ๋ฉ€ํ‹ฐ ์œˆ๋„์šฐ ์ถ”์ฒœ ๋ฐฐ์น˜, ๊ฐ€๋กœ ๋ถ„ํ• , ์„ธ๋กœ ๋ถ„ํ• , ๊ทธ๋ฆฌ๋“œ ๋ฐฐ์น˜๋ฅผ ์ถ”๊ฐ€ +- ๋ฐฐ์น˜ ์ดํ›„ ์ฐฝ ์ˆœ์„œ๋ฅผ ์‚ฌ์šฉ์ž๊ฐ€ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ๋Š” UI๋ฅผ ์ถ”๊ฐ€ +- ํ™œ์„ฑํ™”๋œ ์œˆ๋„์šฐ๊ฐ€ ํ•ญ์ƒ ์ตœ์ƒ๋‹จ์œผ๋กœ ์˜ค๋„๋ก z-index ์ •๋ ฌ์„ ์ถ”๊ฐ€ +- ์ „์ฒดํ™”๋ฉด ๋ฒ„ํŠผ์„ ๋นˆ ๊ณต๊ฐ„ ์šฐ์„  ํ™•์žฅ ํ›„ ๋ถ€๋ชจ ์ „์ฒด ํ™•์žฅ ๋ฐฉ์‹์œผ๋กœ ๋ณด์ • +- `previewer-ui` ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- `text`, `json`, `code`, `image`, `markdown`, `empty` preview ํƒ€์ž…์„ ๊ตฌ์„ฑ +- JSON preview์— ์˜์—ญ๋ณ„ ์ปฌ๋Ÿฌ ํ† ํฐ ํ‘œ์‹œ๋ฅผ ์ถ”๊ฐ€ +- Code preview์— ์–ธ์–ด ์„ ํƒ๊ณผ VSCode ์Šคํƒ€์ผ ํ‘œํ˜„์„ ์ถ”๊ฐ€ +- Previewer ์ƒ˜ํ”Œ์— TypeScript, JSON, HTML, CSS, Bash, SQL, YAML ์˜ˆ์ œ๋ฅผ ์ถ”๊ฐ€ +- ๋ฉ”์ธ ์•ฑ์„ PWA ์˜คํ”„๋ผ์ธ ์บ์‹œ ์ „๋žต์œผ๋กœ ๋ณด๊ฐ• +- ๋ชจ๋ฐ”์ผ ํ—ค๋”/์‚ฌ์ด๋“œ๋ฐ” ๋™์ž‘์„ ์†๋ด์„œ ๋ฉ”๋‰ด ์„ ํƒ ํ๋ฆ„์„ ๋‹จ์ˆœํ™” +- ํ—ค๋” ์•ก์…˜ ์˜์—ญ๊ณผ ์ „์ฒดํ™”๋ฉด ํ† ๊ธ€ ๋™์ž‘์„ ์žฌ์ •๋ฆฌ +- `src/layer/gesture`๋ฅผ ์ถ”๊ฐ€ํ•ด ๋ชจ๋ฐ”์ผ ์šฐ์ธก ์ƒ๋‹จ ์ œ์Šค์ฒ˜๋ฅผ ๋ ˆ์ด์–ด๋กœ ๊ด€๋ฆฌ +- `src/store/appStore`๋ฅผ ์ถ”๊ฐ€ํ•ด ํ˜„์žฌ ํŽ˜์ด์ง€์™€ ํฌ์ปค์Šค ์ปดํฌ๋„ŒํŠธ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌ +- `src/layer/search`๋ฅผ ์ถ”๊ฐ€ํ•ด ํ†ตํ•ฉ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ๊ณผ ๊ฒ€์ƒ‰ ์˜ต์…˜์„ ๋ ˆ์ด์–ด ์ปจํ…์ŠคํŠธ๋กœ ๊ด€๋ฆฌ +- `SearchCommandModal` ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ถ”๊ฐ€ +- ๋ฌธ์„œ, API, ์ปดํฌ๋„ŒํŠธ, ์œ„์ ฏ ํ‚ค์›Œ๋“œ ์ž๋™ ์ถ”์ฒœ๊ณผ ๋น ๋ฅธ ์ด๋™์„ ์—ฐ๊ฒฐ +- ๋ชจ๋ฐ”์ผ ์ž…๋ ฅ ํ™•๋Œ€ ๋ฐฉ์ง€์™€ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ํฌ์ปค์Šค ๋™์ž‘์„ ๋ณด์ • +- ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ ์ถ”์  ์ •๋ฆฌ๋ฅผ ์œ„ํ•ด `.gitignore`๋ฅผ ๋ณด๊ฐ•ํ•˜๊ณ  `app-dist`, `dev-dist` ๋ฌด์‹œ ์ค€๋น„๋ฅผ ์ง„ํ–‰ +- `etc/db/work-db` ๊ฒฝ๋กœ๋กœ PostgreSQL ๋„์ปค ๊ตฌ์„ฑ์„ ๋ถ„๋ฆฌํ•˜๊ณ  `.env` ๊ธฐ๋ฐ˜ ์„ค์ •์„ ์ •๋ฆฌ +- `etc/servers/work-server`์— `Fastify + PostgreSQL` ๊ธฐ๋ฐ˜ ์ž‘์—… API ์„œ๋ฒ„๋ฅผ ์ถ”๊ฐ€ +- Plan ๊ฒŒ์‹œํŒ์šฉ ๋“ฑ๋ก, ์ˆ˜์ •, ์‚ญ์ œ, ํ…Œ์ด๋ธ” ์ƒ์„ฑ API๋ฅผ ์ถ”๊ฐ€ +- Plan ๊ฒŒ์‹œํŒ์ด ์›น์„œ๋ฒ„ ํ”„๋ก์‹œ(`/api`)๋ฅผ ํ†ตํ•ด `work-server`๋ฅผ ๋ฐ”๋ผ๋ณด๋„๋ก ์ˆ˜์ • +- Plan ํ•ญ๋ชฉ ๋“ฑ๋ก ์‹œ ์ž‘์—… ID, ์š”์ฒญ ๋ฉ”๋ชจ, ๋Œ€์ƒ ๋ธŒ๋žœ์น˜/๋ฆด๋ฆฌ์ฆˆ ํ๋ฆ„์„ ํ•จ๊ป˜ ๊ด€๋ฆฌํ•˜๋„๋ก ํ™”๋ฉด๊ณผ ์„œ๋ฒ„ ๋ชจ๋ธ์„ ์ •๋ฆฌ +- Plan ํ•ญ๋ชฉ์„ ์ฃผ๊ธฐ์ ์œผ๋กœ ์ฝ์–ด `๋“ฑ๋ก -> ์ž‘์—…์ค‘(๋ธŒ๋žœ์น˜ ์ค€๋น„)`์™€ `๊ฐœ๋ฐœ์™„๋ฃŒ -> ์™„๋ฃŒ(release merge)` ํ๋ฆ„์„ ์ž๋™ํ™”ํ•˜๋Š” worker๋ฅผ ์ถ”๊ฐ€ +- `scripts/run-plan-codex-once.mjs`๋กœ ๋‹จ๊ฑด Plan ํ•ญ๋ชฉ์„ ์ฝ์–ด Codex ์‹คํ–‰ ๊ฒฐ๊ณผ๋ฅผ ์กฐ์น˜ ์ด๋ ฅ์— ๋ฐ˜์˜ํ•˜๋Š” ์ž๋™ํ™” ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ถ”๊ฐ€ +- Plan ์ž๋™ํ™” ์‹คํŒจ ์‹œ ์ƒํƒœ๋ฅผ `์ด์Šˆ`๋กœ ๋ฐ”๊พธ์ง€ ์•Š๊ณ  ํ•ด์‹œํƒœ๊ทธ/์˜ค๋ฅ˜ ์ด๋ ฅ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋„๋ก ๋ณ€๊ฒฝ +- `etc` ๋‚ด๋ถ€์—์„œ ์ปค๋ฐ‹๋˜๋ฉด ์•ˆ ๋˜๋Š” `.env`, `node_modules`, `dist`, ๋กœ๊ทธ ํŒŒ์ผ์„ ๋ฃจํŠธ `.gitignore`์—์„œ๋„ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธ +- `์ž‘์—…์‹œ์ž‘` ์ดํ›„์—๋Š” ์›๋ณธ ์š”์ฒญ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†๋„๋ก ์ž ๊ธˆ ๊ทœ์น™์„ ์ถ”๊ฐ€ +- ์ž‘์—… ์ดํ›„ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์€ ์ผ๋ฐ˜ ์กฐ์น˜ ์ด๋ ฅ๊ณผ ์ด์Šˆ ์กฐ์น˜ ์ด๋ ฅ์œผ๋กœ ๋ˆ„์  ๊ธฐ๋ก๋˜๋„๋ก ํ™•์žฅ +- ์ปดํฌ๋„ŒํŠธ/ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท์„ ์ƒ์„ฑํ•œ ๋’ค ์ž‘์—…์ผ์ง€ `## ํ™”๋ฉด ์บก์ฒ˜` ์„น์…˜์— ์ž๋™ ๋งํฌํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ •๋ฆฌ +- Plan ๊ฒŒ์‹œํŒ ๋ฉ”๋ชจ ์ƒ์„ธ์˜ ๋ชจ๋ฐ”์ผ ๋†’์ด๋ฅผ ์ ˆ๋ฐ˜ ์ˆ˜์ค€์œผ๋กœ ์ค„์ด๋„๋ก ์กฐ์ • +- ์ž‘์—…์ผ์ง€ ์บก์ฒ˜ ์Šคํฌ๋ฆฝํŠธ๋ฅผ ๊ณตํ†ต ์œ ํ‹ธ๋กœ ์ •๋ฆฌํ•˜๊ณ  ๋ˆ„๋ฝ ๋ฐฉ์ง€์šฉ ๋ชจ๋ฐ”์ผ Plan ์ƒ์„ธ ์บก์ฒ˜๋ฅผ ์ถ”๊ฐ€ + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- `vite-plugin-pwa`๋Š” `vite@8`๊ณผ peer ๊ฒฝ๊ณ ๊ฐ€ ์žˆ์–ด ๋„์ปค ์‹คํ–‰ ์‹œ `npm install --force`๊ฐ€ ํ•„์š” +- ์ž‘์—…์ผ์ง€ ์ด๋ฏธ์ง€ ํ‘œ์‹œ๋ฅผ ์œ„ํ•ด markdown preview๊ฐ€ ์ „์ฒด ๋ธ”๋ก์„ ๋ Œ๋”๋งํ•˜๋„๋ก ์ˆ˜์ • +- ์ปดํฌ๋„ŒํŠธ ์บก์ฒ˜ ์ž๋™ํ™”๋Š” `Playwright` ๊ธฐ๋ฐ˜์œผ๋กœ ์Šคํฌ๋ฆฐ์ƒท์„ ์ƒ์„ฑํ•˜๊ณ  ์ž‘์—…์ผ์ง€ ๋งํฌ๋ฅผ ํ•จ๊ป˜ ์ถ”๊ฐ€ +- ์บก์ฒ˜ ์Šคํฌ๋ฆฝํŠธ๋Š” ๋‚ ์งœ๋ณ„ ํด๋”๋ฅผ ๋งŒ๋“ค๊ณ  ๋™์ผ ๋‚ ์งœ ์ž‘์—…์ผ์ง€์— ์ด๋ฏธ์ง€ Markdown ๋งํฌ๋ฅผ ์ž๋™ ์‚ฝ์ž…ํ•จ +- iOS์—์„œ๋Š” ์ œ์Šค์ฒ˜ ํ›„ ํ‚ค๋ณด๋“œ ์˜คํ”ˆ ๋ณด์žฅ์ด ์ œํ•œ์ ์ด๋ผ ๊ฐ•์ œ ํฌ์ปค์Šค ๋กœ์ง๋ณด๋‹ค ๋ ˆ์ด์–ด ๋ถ„๋ฆฌ์™€ ๋‹จ์ˆœ ๋™์ž‘ ์œ ์ง€์— ์šฐ์„ ์ˆœ์œ„๋ฅผ ๋‘  +- Plan ์ž๋™ํ™”๋Š” ํ˜„์žฌ ์ €์žฅ์†Œ worktree๊ฐ€ ๊นจ๋—ํ•ด์•ผ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ์ด ๊ฐ€๋Šฅํ•จ +- ํ˜„์žฌ ์ €์žฅ์†Œ์—๋Š” `release` ๋ธŒ๋žœ์น˜๊ฐ€ ์•„์ง ์—†์–ด ์‹ค์ œ ์ž๋™ merge๋ฅผ ์“ฐ๋ ค๋ฉด ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ์ด ๋จผ์ € ํ•„์š”ํ•จ +- ์ปจํ…Œ์ด๋„ˆ ๋‚ด๋ถ€ git์€ `safe.directory` ์ด์Šˆ๊ฐ€ ์žˆ์–ด ์ž๋™ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ์ถ”๊ฐ€ํ•จ +- Plan ์ด๋ ฅ ํ…Œ์ด๋ธ” ์ถ”๊ฐ€ ์ดํ›„ ๊ธฐ์กด ์‹คํŒจ ๋ฐ์ดํ„ฐ์—๋Š” ๊ณผ๊ฑฐ ์ด๋ ฅ์ด ์—†์„ ์ˆ˜ ์žˆ์–ด ์žฌ์‹œ๋„๋กœ ์ƒˆ ์ด๋ ฅ์„ ๋งŒ๋“œ๋Š” ๋ฐฉ์‹์œผ๋กœ ์ •๋ฆฌํ•จ +- `run-plan-codex-once`๋Š” ์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ/์ด์Šˆ ์ด๋ ฅ์„ ํ”„๋กฌํ”„ํŠธ์— ํฌํ•จํ•ด ์žฌ์ž‘์—…์ด๋‚˜ ๋ณด์™„ ์š”์ฒญ์„ ์šฐ์„  ๋ฐ˜์˜ํ•˜๋„๋ก ๊ตฌ์„ฑํ•จ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ๊ณตํ†ต ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ปดํฌ๋„ŒํŠธ๋Š” `src/components/previewer` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- ๊ณตํ†ต ์œˆ๋„์šฐ ์ปดํฌ๋„ŒํŠธ๋Š” `src/components/window` ์•„๋ž˜์—์„œ ๊ด€๋ฆฌ +- ์ž‘์—…์ผ์ง€ ์บก์ฒ˜ ์ด๋ฏธ์ง€๋Š” `docs/assets/worklogs/YYYY-MM-DD/`์— ์ €์žฅ +- ์ปดํฌ๋„ŒํŠธ ์บก์ฒ˜๋Š” ์ „์ฒด ํ™”๋ฉด์ด ์•„๋‹ˆ๋ผ ์ƒ˜ํ”Œ ์ปดํฌ๋„ŒํŠธ ์˜์—ญ ์ค‘์‹ฌ์œผ๋กœ ์ƒ์„ฑ +- ์•ฑ ์ƒํƒœ ์ €์žฅ์€ `src/store` +- ํ™”๋ฉด ์ „์—ญ UI๋Š” `src/layer` +- ํ†ตํ•ฉ๊ฒ€์ƒ‰์€ store๊ฐ€ ์•„๋‹ˆ๋ผ layer context๋กœ ๊ด€๋ฆฌ +- ํ”„๋กœ์ ํŠธ ๋‚ด๋ถ€ ๋ถ€๊ฐ€ ์„œ๋ฒ„/DB๋Š” `etc/servers/`, `etc/db/` ๊ตฌ์กฐ๋กœ ๊ด€๋ฆฌ +- `etc` ๋‚ด๋ถ€์˜ ๋น„๋ฐ€๊ฐ’/์ƒ์„ฑ๋ฌผ์€ ๊ฐ ํ•˜์œ„ `.gitignore`์™€ ๋ฃจํŠธ `.gitignore`์—์„œ ์ด์ค‘์œผ๋กœ ์ฐจ๋‹จ +- Plan ๊ฒŒ์‹œํŒ์€ ์ƒํƒœ ์ˆ˜๊ธฐ ๋ณ€๊ฒฝ๋ณด๋‹ค ์ž๋™ํ™” ์ค‘์‹ฌ ํ๋ฆ„์„ ์šฐ์„ ํ•˜๊ณ , ์‚ฌ์šฉ์ž๋Š” ์ƒํƒœ๋ณ„ ํ—ˆ์šฉ ์•ก์…˜ ๋ฒ„ํŠผ๋งŒ ์‚ฌ์šฉ +- ์›๋ณธ ์š”์ฒญ์€ `์ž‘์—…์‹œ์ž‘` ์ดํ›„ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์ „ํ™˜ํ•˜๊ณ  ์ถ”๊ฐ€ ์กฐ์น˜์‚ฌํ•ญ์€ ์ด๋ ฅ์œผ๋กœ๋งŒ ๋‚จ๊น€ +- ์Šคํฌ๋ฆฐ์ƒท ์ž์‚ฐ์€ ๋‚ ์งœ๋ณ„ ์ž‘์—…์ผ์ง€์™€ 1:1๋กœ ์—ฐ๊ฒฐํ•˜๊ณ  ์บก์ฒ˜ ์งํ›„ ๋ฌธ์„œ ๋งํฌ๊นŒ์ง€ ์ž๋™ ๋ฐ˜์˜ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ์ดˆ๊ธฐ ๋ฌธ์„œ์— ๋‚จ์•„ ์žˆ๋˜ ๊ตฌํ˜• `src/components/inputs`, `src/views/main` ๊ธฐ์ค€ ์„ค๋ช…์„ ํ˜„์žฌ `src/app/main`, `src/components/*`, `src/features/planBoard` ๊ตฌ์กฐ์— ๋งž์ถฐ ๊ต์ฒด +- ๋ฃจํŠธ ์•ˆ๋‚ด๋ฌธ์—์„œ ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ, ์•ฑ ์„น์…˜, ๋ฌธ์„œ ์œ„์น˜๋ฅผ ํ•œ ๋ฒˆ์— ํ™•์ธํ•  ์ˆ˜ ์žˆ๋„๋ก ์ •๋ฆฌ +- Docs ๊ฐ€์ด๋“œ์— ์‹ค์ œ Markdown ์ˆ˜์ง‘ ๋ฒ”์œ„์™€ ๋ฌธ์„œ ์ตœ์‹ ํ™” ๊ธฐ๋ก ์›์น™์„ ๋ฐ˜์˜ +- `window-ui`, `previewer-ui` ์ถ”๊ฐ€ ์ดํ›„ ์•ฑ ์ „๋ฐ˜ ๊ตฌ์กฐ๊ฐ€ ์ปดํฌ๋„ŒํŠธ ๋ฌธ์„œ/์ƒ˜ํ”Œ ํ—ˆ๋ธŒ ํ˜•ํƒœ๋กœ ๋” ์„ ๋ช…ํ•ด์ง +- PWA ์˜คํ”„๋ผ์ธ ์บ์‹œ ์ „๋žต๊ณผ ๋ชจ๋ฐ”์ผ ๋ ˆ์ด์•„์›ƒ ๋ณด์ •์„ ํ•จ๊ป˜ ์ง„ํ–‰ํ•ด ์‹ค์ œ ์‚ฌ์šฉ ํ™˜๊ฒฝ ๋Œ€์‘ ๋ฒ”์œ„๋ฅผ ๋„“ํž˜ +- `gesture`, `search`, `store/appStore`๋ฅผ ๋‚˜๋ˆ  ์ „์—ญ UI์™€ ์ƒํƒœ ์ฑ…์ž„์„ ๋ถ„๋ฆฌํ•˜๋Š” ๊ธฐ์ค€์„ ์„ธ์›€ +- ํ†ตํ•ฉ๊ฒ€์ƒ‰์€ ๋ฌธ์„œ, API, ์ปดํฌ๋„ŒํŠธ, ์œ„์ ฏ์„ ํ•œ ํ๋ฆ„์—์„œ ์ฐพ๋„๋ก ๋ฌถ๊ณ  ๋ชจ๋‹ฌ/์ œ์Šค์ฒ˜/ํฌ์ปค์Šค ์ถ”์ ์„ ์—ฐ๊ฒฐ +- ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ ์ถ”์ ์„ ์ •๋ฆฌํ•˜๋ฉฐ `.gitignore`์™€ Git ์ธ๋ฑ์Šค ์šด์˜ ๊ธฐ์ค€๋„ ํ•จ๊ป˜ ์ •๋น„ +- `work-server`๋Š” Plan ๊ฒŒ์‹œํŒ API, DB ์—ฐ๊ฒฐ, worker ์ž๋™ํ™” ์—ญํ• ์„ ํ•จ๊ป˜ ๊ฐ€์ง€๋„๋ก ํ™•์žฅ +- Plan ๊ฒŒ์‹œํŒ์€ ๋‹จ์ˆœ ์ƒํƒœ ๋ณด๋“œ๊ฐ€ ์•„๋‹ˆ๋ผ ๋“ฑ๋ก ์ •๋ณด, ์กฐ์น˜ ์ด๋ ฅ, ์ด์Šˆ ์ด๋ ฅ, ์›๋ณธ ์š”์ฒญ ์ž ๊ธˆ ๊ทœ์น™๊นŒ์ง€ ํ•จ๊ป˜ ๊ด€๋ฆฌํ•˜๋Š” ์ž‘์—… ํ ์„ฑ๊ฒฉ์œผ๋กœ ํ™•์žฅ +- ๋“ฑ๋ก ๋‹จ๊ณ„์—์„œ๋Š” ์ž‘์—… ID์™€ ์š”์ฒญ ๋‚ด์šฉ์„ ๋‚จ๊ธฐ๊ณ , ์ž๋™ํ™” ๋‹จ๊ณ„์—์„œ๋Š” ๋ธŒ๋žœ์น˜ ํ™•๋ณด, Codex ์‹คํ–‰, ์ปค๋ฐ‹/ํ‘ธ์‹œ, release ๋ฐ˜์˜ ๊ฐ€๋Šฅ ์—ฌ๋ถ€๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ํŒ๋‹จํ•˜๋„๋ก ํ๋ฆ„์„ ์ •๋ฆฌ +- ์ž๋™ํ™” worker๋Š” ํ˜„์žฌ ์ €์žฅ์†Œ์— ์ง์ ‘ ๋ธŒ๋žœ์น˜๋ฅผ ๋งŒ๋“ค๊ณ  `release` ๋ธŒ๋žœ์น˜ ๋จธ์ง€๊นŒ์ง€ ์‹œ๋„ํ•˜๋˜, ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ dirty๋ฉด ์ด์Šˆ ํƒœ๊ทธ์™€ ์˜ค๋ฅ˜ ์ด๋ ฅ์„ ๋‚จ๊ธฐ๊ณ  ์•ˆ์ „ํ•˜๊ฒŒ ๋ฉˆ์ถค +- Plan ํ™”๋ฉด์—์„œ๋Š” ๋ธŒ๋žœ์น˜๋ช…, ์ž๋™ํ™” ์ƒํƒœ, ์ตœ๊ทผ ์˜ค๋ฅ˜, ์ด์Šˆ ํƒœ๊ทธ, ์กฐ์น˜ ์ด๋ ฅ์„ ํ•จ๊ป˜ ํ™•์ธํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ณด๊ฐ• +- `์ž‘์—…์‹œ์ž‘` ์ดํ›„ ์„œ๋ฒ„๋Š” ์›๋ณธ ์š”์ฒญ ์ˆ˜์ • API๋ฅผ `409`๋กœ ์ฐจ๋‹จํ•˜๊ณ , ํ™”๋ฉด์—์„œ๋„ ๋ฉ”๋ชจ/์ž‘์—… ID๋ฅผ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ ์ „ํ™˜ +- ์Šคํฌ๋ฆฐ์ƒท ์ฒ˜๋ฆฌ ์Šคํฌ๋ฆฝํŠธ๋Š” `docs/assets/worklogs/YYYY-MM-DD/` ์ €์žฅ, `docs/worklogs/YYYY-MM-DD.md` ๋งํฌ ๋ฐ˜์˜, ์ค‘๋ณต ๋งํฌ ๋ฐฉ์ง€๊นŒ์ง€ ํ•œ ๋ฒˆ์— ์ˆ˜ํ–‰ํ•˜๋„๋ก ๋งž์ถค +- ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ, ํ”„๋ฆฌ๋ทฐ์–ด, ์œˆ๋„์šฐ UI ์บก์ฒ˜๋Š” ๊ฐ๊ฐ ์ƒ˜ํ”Œ ์…€๋ ‰ํ„ฐ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ž˜๋ผ ์ €์žฅํ•ด ์ž‘์—…์ผ์ง€ ์ฆ์  ์ž๋ฃŒ๋กœ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์„ฑ +- Plan ๊ฒŒ์‹œํŒ์€ ๋ชจ๋ฐ”์ผ์—์„œ `์ƒˆ ๋ฉ”๋ชจ` ์ƒ์„ธ๋ฅผ ๋ฐ”๋กœ ์บก์ฒ˜ํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด ๋ฉ”๋ชจ ๋†’์ด ๋ณด์ • ๊ฒฐ๊ณผ๋ฅผ ์ž‘์—…์ผ์ง€์— ๋ฐ”๋กœ ๋‚จ๊ธธ ์ˆ˜ ์žˆ๊ฒŒ ํ•จ + +## ์Šคํฌ๋ฆฐ์ƒท + +![plan-board-mobile-memo-detail](../assets/worklogs/2026-04-02/plan-board-mobile-memo-detail.png) +![previewer-ui](../assets/worklogs/2026-04-02/previewer-ui.png) +![search-command](../assets/worklogs/2026-04-02/search-command.png) +![window-ui](../assets/worklogs/2026-04-02/window-ui.png) + +## ์†Œ์Šค + +- `src/components/window/WindowUI.tsx`: ๋“œ๋ž˜๊ทธ, ๋ฆฌ์‚ฌ์ด์ฆˆ, ๋ถ„ํ•  ๋ฐฐ์น˜๋ฅผ ์ง€์›ํ•˜๋Š” ๊ณตํ†ต ์œˆ๋„์šฐ UI๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/previewer/PreviewerUI.tsx`: `text/json/code/image/markdown` ๋“ฑ ์—ฌ๋Ÿฌ ์ฆ์  ํ˜•์‹์„ ํ•œ ์ปดํฌ๋„ŒํŠธ์—์„œ ๋ฏธ๋ฆฌ๋ณด๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/search/SearchCommandModal.tsx`: ๋ฌธ์„œ, API, ์ปดํฌ๋„ŒํŠธ, ์œ„์ ฏ์„ ํ•œ ๋ฒˆ์— ์ฐพ๋Š” ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/features/planBoard/PlanBoardPage.tsx`: Plan ๊ฒŒ์‹œํŒ UI์™€ ์ƒ์„ธ ํ๋ฆ„์„ ๋ณธ๊ฒฉ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/workers/plan-worker.ts`, `scripts/run-plan-codex-once.mjs`: Plan ์ž๋™ํ™” ์›Œ์ปค์™€ Codex ์‹คํ–‰๊ธฐ๋ฅผ ๋„์ž…ํ•ด ๋ธŒ๋žœ์น˜ ์ค€๋น„, ์ž‘์—… ์‹คํ–‰, ๋ฐ˜์˜ ํ๋ฆ„์„ ์ž๋™ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts ++export class PlanWorker { ++ private readonly workerId: string; ++ private timer: NodeJS.Timeout | null = null; ++ private running = false; +... ++ await this.processRegisteredPlans(); ++ await this.processExecutablePlans(); ++ await this.processReleaseReadyPlans(); + +diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs ++const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null; ++const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex'; +... ++function requiresSourceChange(note) { ++ return /(?:\/|\\).+\.[a-z0-9]+/i.test(text) || /(์ƒ์„ฑ|๋งŒ๋“ค์–ด|์ถ”๊ฐ€|์ˆ˜์ •|๋ณ€๊ฒฝ|์‚ญ์ œ|ํŒŒ์ผ)/.test(text); ++} +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- M scripts/run-plan-codex-once.mjs +- M .gitignore +- M etc/servers/work-server/.env.example +- M etc/servers/work-server/docker-compose.yml +- M etc/servers/work-server/src/services/plan-service.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M src/features/planBoard/PlanBoardPage.tsx +- M src/main.tsx +- M etc/servers/work-server/src/config/env.ts +- M etc/servers/work-server/src/routes/plan.ts +- M etc/servers/work-server/src/services/git-service.ts +- M src/features/planBoard/types.ts +- M package.json +- A scripts/run-plan-codex-once.mjs +- M docs/README.md +- M docs/worklogs/2026-04-02.md +- M src/features/planBoard/api.ts +- M docker-compose.yml +- A etc/db/work-db/.env.example +- A etc/db/work-db/.gitignore +- A etc/db/work-db/README.md +- A etc/db/work-db/docker-compose.yml +- A etc/servers/work-server/.env.example +- A etc/servers/work-server/.gitignore +- A etc/servers/work-server/README.md +- A etc/servers/work-server/docker-compose.yml +- A etc/servers/work-server/package-lock.json +- A etc/servers/work-server/package.json +- A etc/servers/work-server/src/app.ts +- A etc/servers/work-server/src/config/env.ts +- A etc/servers/work-server/src/db/client.ts +- A etc/servers/work-server/src/lib/identifier.ts +- A etc/servers/work-server/src/routes/crud.ts +- A etc/servers/work-server/src/routes/ddl.ts +- A etc/servers/work-server/src/routes/health.ts +- A etc/servers/work-server/src/routes/plan.ts +- A etc/servers/work-server/src/routes/schema.ts +- A etc/servers/work-server/src/server.ts +- A etc/servers/work-server/src/services/git-service.ts +- A etc/servers/work-server/src/services/plan-service.ts +- A etc/servers/work-server/src/workers/plan-worker.ts +- A etc/servers/work-server/tsconfig.json +- M src/app/main/MainContent.tsx +- M src/app/main/MainHeader.tsx +- M src/app/main/MainSidebar.tsx +- M src/app/main/MainView.tsx +- M src/app/main/types.ts +- A src/features/planBoard/PlanBoardPage.tsx +- A src/features/planBoard/api.ts +- A src/features/planBoard/index.ts +- A src/features/planBoard/types.ts +- M src/store/appStore/types/index.ts +- M src/styles.css +- M vite.config.ts +- A docs/assets/worklogs/2026-04-02/search-command.png +- M docs/templates/worklog-template.md +- M docs/worklogs/2026-03-30.md +- M docs/worklogs/2026-03-31.md +- M docs/worklogs/2026-04-01.md +- A scripts/capture-search-command-screenshot.mjs +- D app-dist/apple-touch-icon.svg +- D app-dist/assets/2026-03-30-C9zIPzPv.js +- D app-dist/assets/2026-03-31-DnTx-3Nm.js +- D app-dist/assets/2026-04-01-HPg12E04.js +- D app-dist/assets/AntdIcon-Byo_R91X.js +- D app-dist/assets/CloseOutlined-B6nrJF3-.js +- D app-dist/assets/InputUI-w7gS2eD_.js +- D app-dist/assets/MultiProgressUI-BrtiP5fC.js +- D app-dist/assets/ProgressUI-CcSfL1yk.js +- D app-dist/assets/README-C7FUDFuk.js +- D app-dist/assets/README-CCe9ioJ1.js +- D app-dist/assets/Sample-3CFWfRoz.js +- D app-dist/assets/Sample-AyuQ97sV.css +- D app-dist/assets/Sample-BVdBpN2O.js +- D app-dist/assets/Sample-BX522g-L.js +- D app-dist/assets/Sample-BcLA6P4T.js +- D app-dist/assets/Sample-BdE7za0g.js +- D app-dist/assets/Sample-CJuh1XyL.js +- D app-dist/assets/Sample-CXTqMdzC.js +- D app-dist/assets/Sample-CYEm5q8n.js +- D app-dist/assets/Sample-D8WPIGkO.js +- D app-dist/assets/Sample-DCMU_ANA.js +- D app-dist/assets/Sample-DucAjs70.css +- D app-dist/assets/Sample-H43X1Va0.js +- D app-dist/assets/Sample-H8g2LJhv.js +- D app-dist/assets/Sample-IHgSYgwU.css +- D app-dist/assets/Sample-iMOOVcMn.js +- D app-dist/assets/SearchOutlined-Civ7xtmP.js +- D app-dist/assets/TmsDeliveryFlowSample-BIsYLVLu.js +- D app-dist/assets/TmsDeliveryMetricsSample-Cij5-xON.js +- D app-dist/assets/ValidInputSample-DfYNB-Xp.js +- D app-dist/assets/WidgetShell-BuID0-5g.js +- D app-dist/assets/WmsInboundOutboundSample-D8v85F-k.js +- D app-dist/assets/WmsInventoryTrendSample-BaOj-o94.js +- D app-dist/assets/button-editable-input-TvwRLJmy.png +- D app-dist/assets/card-C6TZ9YUa.js +- D app-dist/assets/check-combo-Bz7kGmN1.js +- D app-dist/assets/clsx-CzIxj0DI.js +- D app-dist/assets/component-plugin-DrEfBYjG.js +- D app-dist/assets/dashboard-report-presets-BmenDHNL.js +- D app-dist/assets/feature-template-C3ggnfNS.js +- D app-dist/assets/index-CR5AbEsh.js +- D app-dist/assets/index-WZ9gt1kR.css +- D app-dist/assets/input-B6oA1SZJ.js +- D app-dist/assets/input.plugin-BigPQCa9.js +- D app-dist/assets/jsx-runtime-CNArSbpp.js +- D app-dist/assets/main-content-fullscreen-toggle-Cppu1H3t.png +- D app-dist/assets/overview-BtWbOP4n.js +- D app-dist/assets/popup-BGFdvx2z.js +- D app-dist/assets/previewer-ui-BheoU6aq.js +- D app-dist/assets/project-setup-B38Eco2m.js +- D app-dist/assets/select-BVXn7KWj.js +- D app-dist/assets/select-oi1Bjv-c.js +- D app-dist/assets/status-badge-C7aul6sS.js +- D app-dist/assets/wave-DQjt-ubw.js +- D app-dist/assets/window-ui-CXDuYQu1.js +- D app-dist/assets/workbox-window.prod.es5-B4qug_J_.js +- D app-dist/assets/worklog-template-Donys780.js +- D app-dist/favicon.svg +- D app-dist/index.html +- D app-dist/manifest.webmanifest +- D app-dist/pwa-192x192.svg +- D app-dist/pwa-512x512.svg +- D app-dist/sw.js +- D app-dist/workbox-8c29f6e4.js +- D dev-dist/sw.js +- D dev-dist/workbox-5a5d9309.js +- A docs/components/search-command.md +- A docs/features/search-layer.md +- M index.html +- A src/components/search/SearchCommandModal.tsx +- A src/components/search/index.ts +- A src/components/search/types.ts +- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx +- M src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx +- M src/index.ts +- A src/layer/gesture/context/GestureContext.tsx +- A src/layer/gesture/hooks/useGestureLayer.ts +- A src/layer/gesture/index.ts +- A src/layer/gesture/types/index.ts +- A src/layer/index.ts +- A src/layer/search/context/SearchLayerContext.tsx +- A src/layer/search/hooks/useSearchLayer.ts +- A src/layer/search/index.ts +- A src/layer/search/types/index.ts +- A src/store/appStore/context/AppStoreContext.tsx +- A src/store/appStore/hooks/useAppStore.ts +- A src/store/appStore/index.ts +- A src/store/appStore/types/index.ts +- A src/store/index.ts +- A app-dist/apple-touch-icon.svg +- R app-dist/assets/2026-03-30-C4SD1FRx.js -> app-dist/assets/2026-03-30-C9zIPzPv.js +- R app-dist/assets/2026-03-31-DwLJWvh2.js -> app-dist/assets/2026-03-31-DnTx-3Nm.js +- D app-dist/assets/2026-04-01-D5gI7Q4h.js +- A app-dist/assets/2026-04-01-HPg12E04.js +- R app-dist/assets/InputUI-DAmC5DJh.js -> app-dist/assets/InputUI-w7gS2eD_.js +- R app-dist/assets/MultiProgressUI--uB5kqTr.js -> app-dist/assets/MultiProgressUI-BrtiP5fC.js +- R app-dist/assets/ProgressUI-C91UL-oJ.js -> app-dist/assets/ProgressUI-CcSfL1yk.js +- R app-dist/assets/README-CI9EVrw_.js -> app-dist/assets/README-C7FUDFuk.js +- R app-dist/assets/README-O9_O-4tf2.js -> app-dist/assets/README-CCe9ioJ1.js +- R app-dist/assets/Sample-CeT4nPqx.js -> app-dist/assets/Sample-3CFWfRoz.js +- A app-dist/assets/Sample-AyuQ97sV.css +- R app-dist/assets/Sample-Dyso1eHr.js -> app-dist/assets/Sample-BVdBpN2O.js +- A app-dist/assets/Sample-BX522g-L.js +- R app-dist/assets/Sample-DKoCtyPX.js -> app-dist/assets/Sample-BcLA6P4T.js +- R app-dist/assets/Sample-DMEGMJwT.js -> app-dist/assets/Sample-BdE7za0g.js +- R app-dist/assets/Sample-BJxnglT1.js -> app-dist/assets/Sample-CJuh1XyL.js +- R app-dist/assets/Sample-xgRr-oUd.js -> app-dist/assets/Sample-CXTqMdzC.js +- R app-dist/assets/Sample-BPCdH5hH.js -> app-dist/assets/Sample-CYEm5q8n.js +- R app-dist/assets/Sample-LB0lRdor.js -> app-dist/assets/Sample-D8WPIGkO.js +- R app-dist/assets/Sample-6Ml90fMj.js -> app-dist/assets/Sample-DCMU_ANA.js +- A app-dist/assets/Sample-DucAjs70.css +- D app-dist/assets/Sample-E6V4D3Du.js +- A app-dist/assets/Sample-H43X1Va0.js +- R app-dist/assets/Sample-CLup9Uwo.js -> app-dist/assets/Sample-H8g2LJhv.js +- A app-dist/assets/Sample-IHgSYgwU.css +- A app-dist/assets/Sample-iMOOVcMn.js +- R app-dist/assets/TmsDeliveryFlowSample-BHeS93-n.js -> app-dist/assets/TmsDeliveryFlowSample-BIsYLVLu.js +- R app-dist/assets/TmsDeliveryMetricsSample-BQV5az65.js -> app-dist/assets/TmsDeliveryMetricsSample-Cij5-xON.js +- R app-dist/assets/ValidInputSample-C9pl9si5.js -> app-dist/assets/ValidInputSample-DfYNB-Xp.js +- R app-dist/assets/WidgetShell-DhXCYrC8.js -> app-dist/assets/WidgetShell-BuID0-5g.js +- R app-dist/assets/WmsInboundOutboundSample-BZCM3_0V.js -> app-dist/assets/WmsInboundOutboundSample-D8v85F-k.js +- R app-dist/assets/WmsInventoryTrendSample-DvxPBjgx.js -> app-dist/assets/WmsInventoryTrendSample-BaOj-o94.js +- A app-dist/assets/button-editable-input-TvwRLJmy.png +- R app-dist/assets/card-BpKFEf6A.js -> app-dist/assets/card-C6TZ9YUa.js +- R app-dist/assets/component-plugin-BjxKibxS.js -> app-dist/assets/component-plugin-DrEfBYjG.js +- R app-dist/assets/dashboard-report-presets-Bh8duNGL.js -> app-dist/assets/dashboard-report-presets-BmenDHNL.js +- R app-dist/assets/feature-template-D3D0o1kc.js -> app-dist/assets/feature-template-C3ggnfNS.js +- R app-dist/assets/index-BQsYfbAI.js -> app-dist/assets/index-CR5AbEsh.js +- D app-dist/assets/index-CaXbpawn.css +- A app-dist/assets/index-WZ9gt1kR.css +- R app-dist/assets/input.plugin-ulF_zEvq.js -> app-dist/assets/input.plugin-BigPQCa9.js +- A app-dist/assets/main-content-fullscreen-toggle-Cppu1H3t.png +- R app-dist/assets/overview-DgYaz2rW.js -> app-dist/assets/overview-BtWbOP4n.js +- A app-dist/assets/previewer-ui-BheoU6aq.js +- R app-dist/assets/project-setup-jU8Nv-E8.js -> app-dist/assets/project-setup-B38Eco2m.js +- R app-dist/assets/select-DYfkmyn8.js -> app-dist/assets/select-BVXn7KWj.js +- R app-dist/assets/select-kIZVYgkF.js -> app-dist/assets/select-oi1Bjv-c.js +- R app-dist/assets/status-badge-1fx0opaz.js -> app-dist/assets/status-badge-C7aul6sS.js +- A app-dist/assets/window-ui-CXDuYQu1.js +- A app-dist/assets/workbox-window.prod.es5-B4qug_J_.js +- R app-dist/assets/worklog-template-DE_f72dx.js -> app-dist/assets/worklog-template-Donys780.js +- A app-dist/favicon.svg +- M app-dist/index.html +- A app-dist/manifest.webmanifest +- A app-dist/pwa-192x192.svg +- A app-dist/pwa-512x512.svg +- A app-dist/sw.js +- A app-dist/workbox-8c29f6e4.js +- A dev-dist/sw.js +- A dev-dist/workbox-5a5d9309.js +- A docs/assets/worklogs/2026-04-01/.gitkeep +- A docs/assets/worklogs/2026-04-01/button-editable-input.png +- A docs/assets/worklogs/2026-04-01/main-content-fullscreen-toggle.png +- A docs/assets/worklogs/2026-04-02/.gitkeep +- A docs/assets/worklogs/2026-04-02/previewer-ui.png +- A docs/assets/worklogs/2026-04-02/window-ui.png +- A docs/components/previewer-ui.md +- A docs/components/window-ui.md +- A docs/worklogs/2026-04-02.md +- M package-lock.json +- A public/apple-touch-icon.svg +- A public/favicon.svg +- A public/pwa-192x192.svg +- A public/pwa-512x512.svg +- A scripts/capture-component-screenshot.mjs +- A scripts/capture-fullscreen-toggle-screenshot.mjs +- M src/app/manifests/docs.manifest.ts +- M src/components/markdownPreview/MarkdownPreviewCard.tsx +- M src/components/markdownPreview/MarkdownPreviewContent.tsx +- A src/components/previewer/PreviewerUI.css +- A src/components/previewer/PreviewerUI.tsx +- A src/components/previewer/index.ts +- A src/components/previewer/samples/Sample.tsx +- A src/components/previewer/types/index.ts +- A src/components/previewer/types/previewer.ts +- A src/components/window/WindowUI.css +- A src/components/window/WindowUI.tsx +- A src/components/window/index.ts +- A src/components/window/samples/Sample.tsx +- A src/components/window/types/index.ts +- A src/components/window/types/window.ts +- M src/vite-env.d.ts +- M tsconfig.lib.json + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npm run build +docker compose up -d +npm run plan:codex:once +node scripts/capture-search-command-screenshot.mjs --date 2026-04-02 +npm run capture:plan-mobile -- --date 2026-04-02 +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `docs/worklogs/2026-04-02.md` +- `src/components/window/WindowUI.tsx` +- `src/components/previewer/PreviewerUI.tsx` +- `src/components/search/SearchCommandModal.tsx` +- `src/features/planBoard/PlanBoardPage.tsx` +- `src/layer/search/context/SearchLayerContext.tsx` +- `src/store/appStore/context/AppStoreContext.tsx` +- `etc/servers/work-server/src/routes/plan.ts` +- `etc/servers/work-server/src/services/plan-service.ts` +- `scripts/run-plan-codex-once.mjs` diff --git a/docs/worklogs/2026-04-03.md b/docs/worklogs/2026-04-03.md new file mode 100755 index 0000000..e625b13 --- /dev/null +++ b/docs/worklogs/2026-04-03.md @@ -0,0 +1,137 @@ +# 2026-04-03 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- Plan ์ž๋™ํ™” ์žฌ์ฒ˜๋ฆฌ ํ๋ฆ„๊ณผ main ๋ฐ˜์˜ ์•ˆ์ •ํ™” +- release/main ๋ธŒ๋žœ์น˜ ์ „๋žต์„ ์ •๋ฆฌํ•˜๊ณ  ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์˜ค๋ฅ˜๋ฅผ ์ˆ˜์ • +- Plan ๋ฉ”๋ชจ ๊ฒ€์ƒ‰ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ +- Plan ์ƒ์„ธ์—์„œ ์†Œ์Šค ์ž‘์—… ์ด๋ ฅ/๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ™•์ธ ํ๋ฆ„ ๋ณด๊ฐ• +- ๋ชจ๋ฐ”์ผ ์„ค์ • ์•„์ด์ฝ˜ ํ„ฐ์น˜ ๋ถˆ๊ฐ€ ์ด์Šˆ๋ฅผ ์ œ์Šค์ฒ˜ ์˜ค๋ฒ„๋ ˆ์ด ์ถฉ๋Œ ์ œ๊ฑฐ๋กœ ํ•ด๊ฒฐ +- ์ž‘์—… ์ƒ์„ธ์˜ ๋ถˆํ•„์š”ํ•œ `๋‚ด์šฉ ์กฐํšŒ` ๋ฒ„ํŠผ ์ œ๊ฑฐ +- Noti ์„ค์ •์˜ ์„œ๋ฒ„ ํ† ํฐ ๋“ฑ๋ก/์‚ญ์ œ ์—ฐ๋™ ๋ณด๊ฐ• +- ์ž‘์—… ID ๊ธฐ๋ณธ๊ฐ’(`์ž‘์—…ID`) ์ฒ˜๋ฆฌ ๋ฐ ์ค‘๋ณต ์ œ์•ฝ ์™„ํ™” +- ์™„๋ฃŒ/๋ฐ˜์˜ ๋‹จ๊ณ„์—์„œ๋„ ์†Œ์Šค ์ž‘์—… ์ด๋ ฅ์ด ๋ˆ„๋ฝ๋˜์ง€ ์•Š๋„๋ก ์ €์žฅ ๋กœ์ง ๋ณด๊ฐ• +- Web Push ๋“ฑ๋ก/ํ˜ธํ™˜ ์ฒ˜๋ฆฌ ๋ณด๊ฐ•(๋ฏธ์ง€์› API 404 ๋…ธ์ถœ ์™„ํ™”) +- 4์›” 3์ผ ์ž‘์—… ์ฆ์ ์šฉ ์Šคํฌ๋ฆฐ์ƒท(์•ฑ ์„ค์ •, ์•Œ๋ฆผ ์„ค์ •) ๋ณด๊ฐ• +- Plan ์ƒ์„ธ ์ฆ์  ํƒญ ํ•˜๋‹จ์— ์Šคํฌ๋ฆฐ์ƒท/์ž‘์—…์ผ์ง€/preview/source ์ „์ฒดํ™”๋ฉด ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ถ”๊ฐ€ + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- release -> main ์ž๋™ ๋จธ์ง€ ์ดํ›„ ๋ธŒ๋žœ์น˜ ์ƒํƒœ ๊ผฌ์ž„์ด ๋ฐ˜๋ณต๋˜์–ด ๋ธŒ๋žœ์น˜ ์ „๋žต ์žฌ์ •๋น„ ํ•„์š” +- ๋ชจ๋ฐ”์ผ ํ„ฐ์น˜ ์ด์Šˆ๋Š” ๊ธฐ๋Šฅ ์˜ค๋ฅ˜๋ณด๋‹ค ๋ ˆ์ด์–ด ๊ฒน์นจ(์˜ค๋ฒ„๋ ˆ์ด) ์˜ํ–ฅ์ด ์ปธ์Œ +- ์•Œ๋ฆผ ๊ธฐ๋Šฅ์€ ๋กœ์ปฌ UI ์ƒํƒœ์™€ ์„œ๋ฒ„ ํ† ํฐ ์ƒํƒœ๊ฐ€ ๋ถ„๋ฆฌ๋˜๋ฉด ์žฌํ˜„ ์–ด๋ ค์šด ์žฅ์• ๋กœ ์ด์–ด์ง +- ์ž‘์—…/์ด์Šˆ ์ด๋ ฅ ๋ˆ„๋ฝ์€ ์žฌ์ฒ˜๋ฆฌ ํŒ๋‹จ ์ •ํ™•๋„์— ์ง์ ‘ ์˜ํ–ฅ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- Plan ์ž๋™ํ™” ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€์€ `main` ์ค‘์‹ฌ์œผ๋กœ ์ •๋ฆฌ +- ์‹คํŒจ ํ›„ ์žฌ์‹คํ–‰์€ ๋ช…์‹œ์  ์žฌ์ฒ˜๋ฆฌ ์š”์ฒญ ๊ธฐ๋ฐ˜์œผ๋กœ ์ง„ํ–‰ +- ์•Œ๋ฆผ ์„ค์ •์€ UI ํ† ๊ธ€๋งŒ์ด ์•„๋‹ˆ๋ผ ์„œ๋ฒ„ ํ† ํฐ ๋“ฑ๋ก/์‚ญ์ œ์™€ ๋™๊ธฐํ™” +- ๋ฉ”๋ชจ/์†Œ์Šค ์ž‘์—… ์ด๋ ฅ์€ ์™„๋ฃŒ ๋‹จ๊ณ„ ์ดํ›„๊นŒ์ง€ ์ผ๊ด€ ์ €์žฅ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ์ž๋™ํ™” ์›Œ์ปค์˜ ์žฌ์ฒ˜๋ฆฌ, ๋ฐ˜์˜, ์ด๋ ฅ ์ €์žฅ ๊ฒฝ๋กœ๋ฅผ ์†๋ด ๋™์ผ ์ด์Šˆ ๋ฐ˜๋ณต ์‹œ ์ถ”์  ๊ฐ€๋Šฅ์„ฑ์„ ๋†’์ž„ +- release/main ์ „๊ฐœ ๊ณผ์ •์—์„œ ๋ฐœ์ƒํ•˜๋˜ ๋ณต๊ตฌ์„ฑ ์ž‘์—…์„ ์ค„์ด๊ธฐ ์œ„ํ•ด ๋ธŒ๋žœ์น˜ ์šด์˜ ๊ทœ์น™์„ ๋‹จ์ˆœํ™” +- Plan ๋ณด๋“œ UX๋Š” ๋ชจ๋ฐ”์ผ ์ ‘๊ทผ์„ฑ(ํ„ฐ์น˜/์กฐํšŒ/๊ฒ€์ƒ‰) ์ค‘์‹ฌ์œผ๋กœ ์šฐ์„  ๊ฐœ์„  +- ์•Œ๋ฆผ ๊ด€๋ จ API ๋ฏธ์ง€์› ์ƒํ™ฉ์—์„œ๋„ ์‚ฌ์šฉ์ž์—๊ฒŒ ๊ณผ๋„ํ•œ ์˜ค๋ฅ˜๋ฅผ ์ง์ ‘ ๋…ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ์™„์ถฉ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ + +## ์Šคํฌ๋ฆฐ์ƒท + +![settings-app](../assets/worklogs/2026-04-03/settings-app.png) +![settings-notification](../assets/worklogs/2026-04-03/settings-notification.png) + +## ์†Œ์Šค + +- `etc/servers/work-server/src/services/git-service.ts`: ์ƒˆ ์ด์Šˆ ๋ธŒ๋žœ์น˜๋ฅผ `release`๊ฐ€ ์•„๋‹ˆ๋ผ `main` ๊ธฐ์ค€์œผ๋กœ ๋งŒ๋“ค๋„๋ก ๋ฐ”๊ฟ” ๋ธŒ๋žœ์น˜ ๊ผฌ์ž„์„ ์ค„์˜€์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/services/plan-service.ts`: ๊ธฐ๋ณธ ์ž‘์—… ID ์ฒ˜๋ฆฌ, ์ค‘๋ณต ์ œ์•ฝ ํ•ด์ œ, ์™„๋ฃŒ ์‹œ ์†Œ์Šค ์ด๋ ฅ ์ €์žฅ, `main` ๋ฐ˜์˜ ์š”์ฒญ ๋‹จ๊ฑดํ™” ๋“ฑ ์ž๋™ํ™” ์ถ”์  ๋กœ์ง์„ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/features/planBoard/PlanBoardPage.tsx`: ์†Œ์Šค ์ž‘์—… ์ด๋ ฅ๊ณผ ์ฆ์  ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ๋ฆ„์„ ์ƒ์„ธ ํ™”๋ฉด์— ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/app/main/MainHeader.tsx`, `src/app/main/notificationApi.ts`: ์•Œ๋ฆผ ์„ค์ •์„ ์„œ๋ฒ„ ํ† ํฐ ์ƒํƒœ์™€ ๋™๊ธฐํ™”ํ•˜๊ณ  ๋ฏธ์ง€์› ํ™˜๊ฒฝ์˜ 404๋ฅผ ์‚ฌ์šฉ์ž ์˜ค๋ฅ˜๋กœ ๊ณผํ•˜๊ฒŒ ๋…ธ์ถœํ•˜์ง€ ์•Š๋„๋ก ์กฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/workers/plan-worker.ts`: ์žฌ์ฒ˜๋ฆฌ์™€ ๋ฐ˜์˜ ํ๋ฆ„์„ `main` ์ค‘์‹ฌ์œผ๋กœ ๋‹ค์‹œ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/etc/servers/work-server/src/services/git-service.ts b/etc/servers/work-server/src/services/git-service.ts +- const baseBranch = releaseTarget || config.releaseBranch; ++ const baseBranch = config.mainBranch; + +diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts +- workId: z.string().trim().min(1), ++ workId: z.string().trim().optional().default('์ž‘์—…ID'), +... ++ await createPlanLifecycleSourceWorkHistory( ++ id, ++ '์ž‘์—…์™„๋ฃŒ ์ฒ˜๋ฆฌ๋กœ release ๋ฐ˜์˜ ๋Œ€๊ธฐ ์ƒํƒœ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.', ++ currentRow.assigned_branch ?? currentRow.release_target ?? 'release', ++ ); +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- M etc/servers/work-server/src/services/plan-service.ts +- M scripts/run-plan-codex-once.mjs +- M etc/servers/work-server/.env.example +- M etc/servers/work-server/README.md +- M etc/servers/work-server/src/config/env.ts +- M etc/servers/work-server/src/services/git-service.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M src/app/main/notificationApi.ts +- M src/app/main/MainHeader.tsx +- M etc/servers/work-server/package-lock.json +- M etc/servers/work-server/package.json +- M etc/servers/work-server/src/routes/notification.ts +- M etc/servers/work-server/src/services/notification-service.ts +- A etc/servers/work-server/src/types/web-push.d.ts +- M src/main.tsx +- A src/sw.ts +- M vite.config.ts +- M src/features/planBoard/PlanBoardPage.tsx +- M etc/servers/work-server/src/routes/plan.ts +- M src/features/planBoard/api.ts +- M src/features/planBoard/types.ts +- A etc/servers/work-server/src/services/plan-notification-service.ts +- A src/app/main/notificationApi.ts +- M src/layer/gesture/context/GestureContext.tsx +- M src/styles.css +- M etc/servers/work-server/src/app.ts +- A etc/servers/work-server/src/routes/notification.ts +- M etc/servers/work-server/src/server.ts +- A etc/servers/work-server/src/services/notification-service.ts +- M README.md +- A docs/assets/worklogs/2026-04-02/plan-board-mobile-memo-detail.png +- M docs/worklogs/2026-04-02.md +- M package.json +- M scripts/capture-component-screenshot.mjs +- M scripts/capture-fullscreen-toggle-screenshot.mjs +- A scripts/capture-plan-board-mobile-screenshot.mjs +- M scripts/capture-search-command-screenshot.mjs +- A scripts/worklog-capture-utils.mjs +- M src/app/main/MainSidebar.tsx +- M docs/README.md +- M index.html +- M src/app/main/MainView.tsx +- M src/app/main/types.ts +- M docker-compose.yml +- M src/features/planBoard/index.ts +- A docs/test001.md +- M etc/servers/work-server/docker-compose.yml + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npm run plan:codex:once +npm run build +npm run capture:settings -- --date 2026-04-03 +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `docs/worklogs/2026-04-03.md` +- `src/features/planBoard/PlanBoardPage.tsx` +- `etc/servers/work-server/src/workers/plan-worker.ts` +- `etc/servers/work-server/src/services/plan-service.ts` +- `etc/servers/work-server/src/services/git-service.ts` +- `src/app/main/MainHeader.tsx` +- `src/app/main/notificationApi.ts` +- `src/layer/gesture/context/GestureContext.tsx` +- `scripts/run-plan-codex-once.mjs` +- `src/styles.css` diff --git a/docs/worklogs/2026-04-04.md b/docs/worklogs/2026-04-04.md new file mode 100755 index 0000000..4a8610b --- /dev/null +++ b/docs/worklogs/2026-04-04.md @@ -0,0 +1,104 @@ +# 2026-04-04 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- Plan ๋ชฉ๋ก ์กฐํšŒ ๋ฒ„ํŠผ์„ ์ž๋™์กฐํšŒ ์ค‘์‹ฌ UX๋กœ ๊ฐœํŽธ(๋‚จ์€ ์‹œ๊ฐ„ ํ‘œ์‹œ/ํ† ๊ธ€) +- ์ž๋™์กฐํšŒ ๋ฒ„ํŠผ ๋ ˆ์ด์•„์›ƒ ๊นจ์ง/๋ฌธ๊ตฌ ์ค‘๋ณต/์„ ํƒ ๋“œ๋ž˜๊ทธ ๋ฌธ์ œ ๋ณด์ • +- ์•ฑ ์—…๋ฐ์ดํŠธ ์ง„ํ–‰ UI์— ๋‹จ๊ณ„ ํ‘œ์‹œ์™€ ํ”„๋กœ๊ทธ๋ ˆ์Šค ํ๋ฆ„ ์ถ”๊ฐ€ +- ์„œ๋น„์Šค์›Œ์ปค ๊ต์ฒด ํ๋ฆ„(`SKIP_WAITING`, ๋“ฑ๋ก ํ™•์ธ, ํƒ€์ž„์•„์›ƒ) ์•ˆ์ •ํ™” +- ์—…๋ฐ์ดํŠธ ์‹คํŒจ ์‹œ ์ƒํƒœ ๋ณต๊ตฌ์™€ ์˜ค๋ฅ˜ ๊ฐ€์‹œ์„ฑ ๊ฐœ์„  +- `vite-plugin-pwa` ์—…๋ฐ์ดํŠธ ๊ฒฝ๋กœ๋ฅผ ๊ณต์‹ ํ๋ฆ„ ์šฐ์„ ์œผ๋กœ ์ •๋ฆฌ +- ์ž๋™ํ™” Codex ์‹คํ–‰ ํƒ€์ž„์•„์›ƒ ๋„์ž… ๋ฐ ๋กค๋ฐฑ ๊ณผ์ • ๋ฐ˜์˜ +- Plan ์‚ญ์ œ ์‘๋‹ต/์ •๋ฆฌ ๋กœ์ง ๊ฐœ์„  +- work-server ๋ฉ”์ธ ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ/ํ’€ ๋™์ž‘ ์ •๋ฆฌ + +## ์ด์Šˆ ๋ฐ ๋ฉ”๋ชจ + +- ์—…๋ฐ์ดํŠธ ์ด์Šˆ๋Š” ๋‹จ์ผ ์›์ธ๋ณด๋‹ค ์„œ๋น„์Šค์›Œ์ปค ๊ต์ฒด ํƒ€์ด๋ฐ/๋“ฑ๋ก ์ƒํƒœ/ํ™˜๊ฒฝ ์„ค์ •์ด ๋ณตํ•ฉ์ ์œผ๋กœ ์˜ํ–ฅ +- ์ž๋™์กฐํšŒ๋Š” ํ…์ŠคํŠธ/๋ฐฐ์ง€/์˜ค๋ฒ„๋ ˆ์ด๊ฐ€ ๊ฒน์น˜๋ฉด UX๊ฐ€ ๋น ๋ฅด๊ฒŒ ์•…ํ™”๋จ +- Codex ํƒ€์ž„์•„์›ƒ์€ ์•ˆ์ „์žฅ์น˜๊ฐ€ ๋˜์ง€๋งŒ ๊ณผ๋„ํ•˜๋ฉด ์ •์ƒ ์ž‘์—…๋„ ์ค‘๋‹จ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Œ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ์—…๋ฐ์ดํŠธ ์ƒํƒœ๋Š” ๋‹จ์ˆœ ์„ฑ๊ณต/์‹คํŒจ๊ฐ€ ์•„๋‹Œ ๋‹จ๊ณ„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋…ธ์ถœ +- ์„œ๋น„์Šค์›Œ์ปค ๋“ฑ๋ก/๊ต์ฒด๋Š” ์‹คํŒจ ๋ณต๊ตฌ ๊ฒฝ๋กœ๊นŒ์ง€ ํฌํ•จํ•ด ์„ค๊ณ„ +- ์ž๋™์กฐํšŒ UI๋Š” ๋ฒ„ํŠผ ๋ณธ๋ฌธ ์ตœ์†Œํ™” + ๋ณด์กฐ ๋ฐฐ์ง€ ๋ฐฉ์‹ ์œ ์ง€ +- ์›Œ์ปค ๊ฒฝ๋กœ ์„ค์ •์€ ํ™˜๊ฒฝ๋ณ„ ๋ช…ํ™•ํ•œ ๊ธฐ์ค€ ๊ฒฝ๋กœ๋กœ ํ†ต์ผ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- Plan ๋ณด๋“œ์—์„œ๋Š” ์ž๋™์กฐํšŒ ์ค‘์‹ฌ ์šด์˜์„ ์œ„ํ•ด ์ƒ๋‹จ ์•ก์…˜์„ ์žฌ์„ค๊ณ„ํ•˜๊ณ  ๋ชจ๋ฐ”์ผ/๋ฐ์Šคํฌํ†ฑ ํ‘œ์‹œ ๊ท ํ˜•์„ ๋งž์ถค +- ์•ฑ ์—…๋ฐ์ดํŠธ ์˜์—ญ์€ ์‚ฌ์šฉ์ž ์ฒด๊ฐ ๊ธฐ์ค€(์ง€๊ธˆ ๋ฌด์—‡์„ ํ•˜๋Š”์ง€)์œผ๋กœ ๋ฌธ๊ตฌ์™€ ์ƒํƒœ ์ „์ด๋ฅผ ์žฌ๊ตฌ์„ฑ +- ์„œ๋น„์Šค์›Œ์ปค ๊ด€๋ จ ๋ฐ˜๋ณต ์žฅ์• ๋ฅผ ์ค„์ด๊ธฐ ์œ„ํ•ด ๋“ฑ๋ก ํ™•์ธ, ๊ต์ฒด ๋ฉ”์‹œ์ง€ ์ฒ˜๋ฆฌ, ํƒ€์ž„์•„์›ƒ/๋กค๋ฐฑ ๋กœ์ง์„ ํ•จ๊ป˜ ์ ๊ฒ€ +- ์„œ๋ฒ„ ์ธก ๊ฒฝ๋กœ ๋ฐ ์‚ญ์ œ ์ฒ˜๋ฆฌ ๊ฐœ์„ ์œผ๋กœ ์šด์˜ ์ค‘ ๋ฐœ์ƒํ•˜๋˜ ์ž”์—ฌ ๋ฐ์ดํ„ฐ/์‘๋‹ต ์ผ๊ด€์„ฑ ๋ฌธ์ œ๋ฅผ ์ค„์ž„ + +## ์Šคํฌ๋ฆฐ์ƒท + +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท ์—†์Œ + +## ์†Œ์Šค + +- `src/app/main/appUpdate.ts`, `src/app/main/MainHeader.tsx`: ์•ฑ ์—…๋ฐ์ดํŠธ๋ฅผ ๋‹จ๊ณ„ํ˜• ์ƒํƒœ์™€ ํ”„๋กœ๊ทธ๋ ˆ์Šค UI๋กœ ๋…ธ์ถœํ•˜๊ณ  ์„œ๋น„์Šค์›Œ์ปค ๋“ฑ๋ก ๋Œ€๊ธฐ/์žฌ์‹œ๋„ ํ๋ฆ„์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/features/planBoard/PlanBoardPage.tsx`: ์ž๋™์กฐํšŒ ๋ฒ„ํŠผ UX์™€ Plan ๋ชฉ๋ก ์ƒ๋‹จ ์•ก์…˜ ๊ตฌ์„ฑ์„ ์†๋ดค์Šต๋‹ˆ๋‹ค. +- `scripts/run-plan-codex-once.mjs`: Codex ์ž์‹ ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ/์—๋Ÿฌ๋ฅผ ์ค‘๋ณต ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š๋„๋ก `settled` ๊ฐ€๋“œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/services/plan-service.ts`: Plan ์‚ญ์ œ ์‹œ ์—ฐ๊ด€ ์†Œ์Šค์ž‘์—…/์กฐ์น˜/์ด์Šˆ ์ด๋ ฅ๊นŒ์ง€ ํ•จ๊ป˜ ์ •๋ฆฌํ•˜๋„๋ก ํŠธ๋žœ์žญ์…˜ ์‚ญ์ œ๋กœ ๋ฐ”๊ฟจ์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/workers/plan-worker.ts`: ํƒ€์ž„์•„์›ƒ๊ณผ ์‹คํŒจ ์ฒ˜๋ฆฌ ํ๋ฆ„์„ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts +- await db(PLAN_TABLE).where({ id }).delete(); ++ await db.transaction(async (trx) => { ++ await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete(); ++ await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete(); ++ await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete(); ++ await trx(PLAN_TABLE).where({ id }).delete(); ++ }); + +diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx ++import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate'; +... ++ ++ ++ ++ +``` + +```diff +diff --git a/src/components/previewer/renderers.tsx b/src/components/previewer/renderers.tsx ++export function renderMarkdownSectionPreview(section, context) { ++ if (section.type === 'code') return ; ++ if (section.type === 'command') return ; ++ return ; ++} +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- M src/app/main/MainChatPanel.tsx +- M src/styles.css +- M etc/servers/work-server/src/services/app-config-service.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M src/app/main/MainHeader.tsx +- M src/components/window/WindowUI.tsx +- M src/app/main/MainSidebar.tsx +- M src/app/main/MainView.tsx +- M etc/servers/work-server/src/services/plan-service.ts +- M src/features/planBoard/PlanBoardPage.tsx +- M src/features/planBoard/api.ts +- M etc/servers/work-server/src/services/plan-policy.test.ts +- M etc/servers/work-server/src/app.ts +- M etc/servers/work-server/src/services/error-log-service.ts +- M src/app/main/errorLogApi.ts +- M src/app/main/MainContent.tsx +- M src/app/main/types.ts +- A etc/servers/work-server/src/routes/error-log.ts +- A etc/servers/work-server/src/services/error-log-service.ts +- M src/App.tsx +- A src/app/main/errorLogApi.ts +- M etc/servers/work-server/src/routes/app-config.ts +- M src/app/main/appConfig.ts +- A etc/servers/work-server/src/routes/app-config.ts +- A etc/servers/work-server/src/services/app-config-service.ts +- M etc/servers/work-server/src/config/env.ts +- M etc/servers/work-server/src/routes/plan.ts +- M etc/servers/work-server/src/services/git-service.ts +- M etc/servers/work-server/src/services/notification-service.ts +- A src/app/main/appConfig.ts +- M etc/servers/work-server/src/services/chat-service.ts +- M src/features/planBoard/charts.tsx +- M src/features/planBoard/types.ts +- A docs/worklogs/2026-04-03.md +- A docs/worklogs/2026-04-04.md +- A docs/worklogs/2026-04-05.md +- A docs/worklogs/2026-04-06.md +- M etc/servers/work-server/src/services/plan-notification-service.ts +- M etc/servers/work-server/package.json +- A etc/servers/work-server/src/services/plan-notification-policy.ts +- A etc/servers/work-server/src/services/plan-policy.test.ts +- A etc/servers/work-server/src/services/plan-retry-policy.ts +- M src/sw.js + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +npx tsc -b +npm run build:app +npm run capture:menu -- --date 2026-04-06 +npm run capture:feature -- --date 2026-04-06 +npm run plan:codex:once +``` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- `docs/templates/worklog-template.md` +- `docs/worklogs/2026-03-30.md` +- `docs/worklogs/2026-03-31.md` +- `docs/worklogs/2026-04-01.md` +- `docs/worklogs/2026-04-02.md` +- `docs/worklogs/2026-04-03.md` +- `docs/worklogs/2026-04-04.md` +- `docs/worklogs/2026-04-05.md` +- `docs/worklogs/2026-04-06.md` +- `etc/servers/work-server/src/routes/app-config.ts` +- `etc/servers/work-server/src/routes/error-log.ts` +- `etc/servers/work-server/src/routes/plan.ts` +- `etc/servers/work-server/src/services/app-config-service.ts` +- `etc/servers/work-server/src/services/chat-service.ts` +- `etc/servers/work-server/src/services/error-log-service.ts` +- `etc/servers/work-server/src/services/plan-service.ts` +- `etc/servers/work-server/src/workers/plan-worker.ts` +- `scripts/capture-feature-screenshot.mjs` +- `scripts/capture-menu-screenshot.mjs` +- `scripts/worklog-capture-utils.mjs` +- `src/app/main/MainChatPanel.tsx` +- `src/app/main/MainHeader.tsx` +- `src/app/main/appConfig.ts` +- `src/app/main/errorLogApi.ts` +- `src/components/markdownPreview/MarkdownPreviewCard.tsx` +- `src/components/markdownPreview/MarkdownPreviewContent.tsx` +- `src/components/previewer/PreviewerUI.css` +- `src/components/previewer/PreviewerUI.tsx` +- `src/components/previewer/renderers.tsx` +- `src/features/planBoard/PlanBoardPage.tsx` +- `src/features/planBoard/api.ts` +- `src/styles.css` diff --git a/docs/worklogs/2026-04-07.md b/docs/worklogs/2026-04-07.md new file mode 100755 index 0000000..0dc26dc --- /dev/null +++ b/docs/worklogs/2026-04-07.md @@ -0,0 +1,159 @@ +# 2026-04-07 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ๋ˆ„๋ฝ๋œ `2026-04-07` ์ž‘์—…์ผ์ง€ ํŒŒ์ผ์„ ๋ณต๊ตฌ +- Plan ์žฌ์ฒ˜๋ฆฌ ์š”์ฒญ ์ด๋ ฅ๊ณผ ์ž๋™์ž‘์—… ์‹คํŒจ ์ด์Šˆ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์˜ค๋Š˜ ์ƒํƒœ๋ฅผ ๋ฌธ์„œํ™” +- `Docs > ์ž‘์—…์ผ์ง€` ํ™”๋ฉด ์บก์ฒ˜๋ฅผ ์ƒ์„ฑํ•ด ์˜ค๋Š˜์ž ์Šคํฌ๋ฆฐ์ƒท ์ž์‚ฐ๊ณผ ๋ฌธ์„œ ๋งํฌ๋ฅผ ์—ฐ๊ฒฐ +- `## ์†Œ์Šค`, `## ์‹คํ–‰ ์ปค๋งจ๋“œ`, `## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ` ์„น์…˜์„ ํ˜„์žฌ ์ •๋ฆฌ ๊ฒฐ๊ณผ ๊ธฐ์ค€์œผ๋กœ ๊ฐฑ์‹  + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- ์˜ค๋Š˜์ž ์ž‘์—…์ผ์ง€๊ฐ€ ์—†์–ด Docs์™€ Plan ์ฆ์ ์—์„œ `2026-04-07` ๊ธฐ๋ก์„ ๋ฐ”๋กœ ํ™•์ธํ•  ์ˆ˜ ์—†์—ˆ์Œ +- ๋ณ„๋„ ๊ธฐ๋Šฅ ์ˆ˜์ • ์—†์ด ๋ˆ„๋ฝ๋œ ์ผ์ž ๋ฌธ์„œ๋ฅผ ์ถ”๊ฐ€ํ•ด ์กฐํšŒ ๊ณต๋ฐฑ์„ ํ•ด์†Œ +- ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜์ด ๋น„์–ด ์žˆ์–ด ์˜ค๋Š˜ ์ž‘์—… ํ™”๋ฉด์„ ๋ฐ”๋กœ ๊ฒ€์ฆํ•˜๊ธฐ ์–ด๋ ค์› ์Œ +- `vite` ๊ฐœ๋ฐœ ์„œ๋ฒ„์™€ `capture:feature` ์Šคํฌ๋ฆฝํŠธ๋กœ `feature-docs-worklogs.png`๋ฅผ ์ƒ์„ฑํ•ด ๋ฌธ์„œ์™€ ์—ฐ๊ฒฐ + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ์˜ค๋Š˜ ์กฐ์น˜ ๋ฒ”์œ„๋Š” ์ž‘์—…์ผ์ง€ ์ •๋ฆฌ์™€ ์ฆ์  ๋ณด๊ฐ•์œผ๋กœ ํ•œ์ • +- 2026-04-07 ์ฆ์  ๋Œ€ํ‘œ ํ™”๋ฉด์€ `Docs > ์ž‘์—…์ผ์ง€` ๋ทฐ๋กœ ํ†ต์ผ +- ์•ฑ ๋ฒˆ๋“ค ํƒ€์ž… ์˜ค๋ฅ˜๊ฐ€ ๋‚จ์•„ ์žˆ์–ด ์ด๋ฒˆ ์บก์ฒ˜๋Š” `build:app` ๋Œ€์‹  `vite` ๊ฐœ๋ฐœ ์„œ๋ฒ„ ๊ธฐ์ค€์œผ๋กœ ์ˆ˜ํ–‰ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- `docs/worklogs/2026-04-07.md`๋ฅผ ์ƒ์„ฑํ•ด ์˜ค๋Š˜์ž ์ž‘์—…์ผ์ง€ ๊ธฐ๋ณธ ์„น์…˜์„ ์ฑ„์›€ +- ์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ์˜ ์žฌ์ฒ˜๋ฆฌ ์š”์ฒญ๊ณผ ์ž๋™์ž‘์—… ์‹คํŒจ ์ด๋ ฅ์„ ์ž‘์—…์ผ์ง€ ๋งฅ๋ฝ์— ๋งž๊ฒŒ ์ •๋ฆฌ +- `docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png`๋ฅผ ์ƒ์„ฑํ•˜๊ณ  `## ์Šคํฌ๋ฆฐ์ƒท`์— ์—ฐ๊ฒฐ +- ์‹คํ–‰ํ•œ ํ™•์ธ/์บก์ฒ˜ ์ปค๋งจ๋“œ์™€ ์˜ค๋Š˜ ๊ธฐ์ค€ ๋ณ€๊ฒฝ ํŒŒ์ผ ์ฆ์ ์„ ๋ฌธ์„œ ํ•˜๋‹จ ์„น์…˜์— ์ •๋ฆฌ + +## ์Šคํฌ๋ฆฐ์ƒท + +![feature-docs-worklogs](../assets/worklogs/2026-04-07/feature-docs-worklogs.png) + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `docs/worklogs/2026-04-07.md` + +- ๋ˆ„๋ฝ๋œ ์ž‘์—…์ผ์ง€ ๋ณต๊ตฌ ํ›„ ์ตœ์‹  ์ฆ์ ๊นŒ์ง€ ํฌํ•จํ•˜๋„๋ก ์˜ค๋Š˜์ž ๋ฌธ์„œ๋ฅผ ๋‹ค์‹œ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ์ž‘์—… ์š”์•ฝ, ์Šคํฌ๋ฆฐ์ƒท ๋งํฌ, ์‹คํ–‰ ์ปค๋งจ๋“œ, ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ ๋ชฉ๋ก์„ ์ตœ์‹  ์ƒํƒœ๋กœ ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/docs/worklogs/2026-04-07.md b/docs/worklogs/2026-04-07.md ++## ์Šคํฌ๋ฆฐ์ƒท ++![feature-docs-worklogs](../assets/worklogs/2026-04-07/feature-docs-worklogs.png) ++## ์‹คํ–‰ ์ปค๋งจ๋“œ ++npm run dev -- --host 127.0.0.1 --port 4173 +``` + +### ํŒŒ์ผ 2: `docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png` + +- `Docs > ์ž‘์—…์ผ์ง€` ํ™”๋ฉด์„ ์˜ค๋Š˜์ž ์ž์‚ฐ ํด๋”์— ์ €์žฅํ•ด ๋ฌธ์„œ ์ฆ์ ์—์„œ ๋ฐ”๋กœ ๋ฏธ๋ฆฌ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค. +- 2026-04-07 ์ž‘์—…์ผ์ง€์˜ ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜๊ณผ 1:1๋กœ ๋Œ€์‘ํ•˜๋Š” ์บก์ฒ˜ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค. + +```diff +Binary file added: docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, KST ๊ธฐ์ค€) + +- M src/app/main/MainHeader.tsx +- M scripts/run-plan-codex-once.mjs +- M docs/worklogs/2026-04-07.md +- M etc/servers/work-server/src/services/app-config-service.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M src/app/main/appConfig.ts +- A .github/workflows/daily-docs-maintenance.yml +- M README.md +- M docs/README.md +- A docs/assets/worklogs/2026-04-07/.gitkeep +- A docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png +- A docs/daily-maintenance.config.json +- M docs/worklogs/2026-03-30.md +- M docs/worklogs/2026-03-31.md +- M docs/worklogs/2026-04-01.md +- M docs/worklogs/2026-04-02.md +- M docs/worklogs/2026-04-03.md +- M docs/worklogs/2026-04-04.md +- M docs/worklogs/2026-04-05.md +- M docs/worklogs/2026-04-06.md +- M package.json +- A scripts/refresh-daily-docs.mjs +- M src/features/planBoard/PlanBoardPage.tsx +- A etc/servers/work-server/src/services/plan-service.test.ts +- M etc/servers/work-server/src/services/plan-service.ts +- A docs/worklogs/2026-04-07.md +- M docs/templates/worklog-template.md +- M etc/servers/work-server/src/services/chat-service.ts +- M src/components/markdownPreview/MarkdownPreviewCard.tsx +- M scripts/capture-component-screenshot.mjs +- M scripts/capture-feature-screenshot.mjs +- M scripts/capture-fullscreen-toggle-screenshot.mjs +- M scripts/capture-menu-screenshot.mjs +- M scripts/capture-plan-board-mobile-screenshot.mjs +- M scripts/capture-search-command-screenshot.mjs +- M scripts/capture-settings-screenshot.mjs +- M scripts/worklog-capture-utils.mjs +- M src/components/markdownPreview/MarkdownPreviewContent.tsx +- M src/components/previewer/PreviewerUI.css +- M src/components/previewer/PreviewerUI.tsx +- A src/components/previewer/renderers.tsx +- M src/components/previewer/types/index.ts +- M src/components/previewer/types/previewer.ts +- M etc/servers/work-server/src/routes/plan.ts +- M src/features/planBoard/types.ts +- M src/features/planBoard/api.ts +- M src/styles.css +- M src/app/main/MainContent.tsx +- M src/app/main/MainView.tsx +- R docs/assets/worklogs/2026-04-03/feature-chat-live.png -> docs/assets/worklogs/2026-04-05/feature-chat-live.png +- R docs/assets/worklogs/2026-04-03/feature-plans-charts.png -> docs/assets/worklogs/2026-04-05/feature-plans-charts.png +- A docs/assets/worklogs/2026-04-03/feature-chat-live.png +- A docs/assets/worklogs/2026-04-03/feature-plans-charts.png +- A docs/assets/worklogs/2026-04-03/settings-app.png +- A docs/assets/worklogs/2026-04-03/settings-notification.png +- A scripts/capture-settings-screenshot.mjs +- D docs/assets/worklogs/2026-04-06/docs-menu.png +- D docs/assets/worklogs/2026-04-06/feature-apis-components.png +- D docs/assets/worklogs/2026-04-06/feature-docs-worklogs.png +- D docs/assets/worklogs/2026-04-06/feature-plans-board.png +- D docs/assets/worklogs/2026-04-06/feature-plans-charts.png +- D docs/assets/worklogs/2026-04-06/plans-menu.png +- A docs/assets/worklogs/2026-04-06/docs-menu.png +- A docs/assets/worklogs/2026-04-06/feature-apis-components.png +- A docs/assets/worklogs/2026-04-06/feature-docs-worklogs.png +- A docs/assets/worklogs/2026-04-06/feature-plans-board.png +- A docs/assets/worklogs/2026-04-06/feature-plans-charts.png +- A docs/assets/worklogs/2026-04-06/plans-menu.png +- A scripts/capture-feature-screenshot.mjs +- A scripts/capture-menu-screenshot.mjs +- A docs/assets/worklogs/2026-03-30/input.png +- A docs/assets/worklogs/2026-03-30/status-badge.png +- A docs/assets/worklogs/2026-03-31/email-input.png +- A docs/assets/worklogs/2026-03-31/multi-input.png +- A docs/assets/worklogs/2026-04-01/check-combo-input.png +- A docs/assets/worklogs/2026-04-01/dashboard-multi-progress.png +- A docs/assets/worklogs/2026-04-01/dashboard-progress.png +- A docs/assets/worklogs/2026-04-01/popup-input.png +- A docs/assets/worklogs/2026-04-01/select-input.png +- M etc/servers/work-server/src/services/plan-notification-service.ts +- M etc/servers/work-server/src/services/plan-policy.test.ts +- M src/app/main/MainChatPanel.tsx + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +git -c safe.directory=/workspace/auto_codex/repo status --short --branch +sed -n '1,260p' docs/worklogs/2026-04-07.md +sed -n '1,260p' docs/templates/worklog-template.md +npm run dev -- --host 127.0.0.1 --port 4173 +CAPTURE_BASE_URL=http://127.0.0.1:4173 npm run capture:feature -- docs-worklogs 2026-04-07 +git -c safe.directory=/workspace/auto_codex/repo diff -- docs/worklogs/2026-04-07.md +``` + +## ์ž๋™ ์ •๋ฆฌ ์ƒํƒœ + +- ์ผ์ผ ์ •๋ฆฌ ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰์ผ: `2026-04-07` +- ์ž‘์—…์ผ์ง€ ํŒŒ์ผ ์ ๊ฒ€: `docs/worklogs/2026-04-07.md` +- ์Šคํฌ๋ฆฐ์ƒท ํด๋” ์ ๊ฒ€: `docs/assets/worklogs/2026-04-07/` +- README / Docs ๊ฐ€์ด๋“œ ์ž๋™ ์š”์•ฝ ๊ฐฑ์‹  ์™„๋ฃŒ diff --git a/docs/worklogs/2026-04-08.md b/docs/worklogs/2026-04-08.md new file mode 100755 index 0000000..fb6b5a6 --- /dev/null +++ b/docs/worklogs/2026-04-08.md @@ -0,0 +1,188 @@ +# 2026-04-08 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- `Play` ์ƒ๋‹จ ๋ฉ”๋‰ด์™€ `Play > Test` ์ง„์ž… ๊ฒฝ๋กœ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ํ—ค๋” ์ตœ์ƒ๋‹จ ๋ฉ”๋‰ด๋ฅผ `Docs / Plan / Play` 3๊ฐœ ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `Play > layout` ํ™”๋ฉด์„ ๋งŒ๋“ค๊ณ  ์ƒํ•˜/์ขŒ์šฐ ๋ถ„ํ• , ์žฌ๊ท€ ๋ถ„ํ• , ํฌ๊ธฐ ๋‹จ์œ„, ์ตœ์†Œ ํฌ๊ธฐ, ๋ฆฌ์‚ฌ์ด์ฆˆ ์—ฌ๋ถ€๋ฅผ ์กฐ์ ˆํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. +- Plan ์ƒ์„ธ์—์„œ ๊ธด `issue`/`error` ๋ณธ๋ฌธ์„ ๊ธฐ๋ณธ ์ ‘ํž˜์œผ๋กœ ๋ฐ”๊พธ๊ณ  ์•„์ด์ฝ˜์œผ๋กœ๋งŒ ํŽผ์น˜๋„๋ก ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `release` ํ™˜๊ฒฝ์—์„œ `main` ๋ฏธ๋ฐ˜์˜ ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ๋ณด์—ฌ์ฃผ๋Š” ๋ชจ๋‹ฌ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์ž๋™ํ™” ์ž‘์—… ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€์„ `release`๊ฐ€ ์•„๋‹ˆ๋ผ `main`์œผ๋กœ ๋ฐ”๋กœ์žก๊ณ , worklog ์ž๋™ํ™”๋Š” `hotfix/*` ๋ธŒ๋žœ์น˜๋ฅผ ์“ฐ๋„๋ก ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๋ˆ„๋ฝ๋๋˜ `docs/worklogs/2026-04-08.md` ์ดˆ์•ˆ์ด ํ…œํ”Œ๋ฆฟ ์ƒํƒœ๋กœ๋งŒ ๋‚จ์•„ ์žˆ๋˜ ๋ฌธ์ œ๋ฅผ ์‹ค์ œ ์ž‘์—… ๋‚ด์—ญ ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๋ˆ„๋ฝ๋๋˜ `Play > layout` ์Šคํฌ๋ฆฐ์ƒท์„ `docs/assets/worklogs/2026-04-08/feature-play-layout.png`๋กœ ๋‹ค์‹œ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- ์˜ค๋Š˜์ž ์ž‘์—…์ผ์ง€๊ฐ€ ํ…œํ”Œ๋ฆฟ ๋ฌธ๊ตฌ๋งŒ ๋‚จ์•„ ์žˆ์–ด `play`, ์ž๋™ํ™” ๋ธŒ๋žœ์น˜ ์ •์ฑ…, release ๋Œ€๊ธฐ ๋ชจ๋‹ฌ ์ž‘์—… ์ด๋ ฅ์„ ๋ฌธ์„œ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์—†์—ˆ์Šต๋‹ˆ๋‹ค. +- Git ๊ธฐ๋ก ๊ธฐ์ค€์œผ๋กœ 2026-04-08 UTC/KST ์ปค๋ฐ‹์„ ๋‹ค์‹œ ํ™•์ธํ•ด ์‹ค์ œ ์ž‘์—… ํ•ญ๋ชฉ๊ณผ ๋ณ€๊ฒฝ ํŒŒ์ผ์„ ๋ณต์›ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์‚ฌ์šฉ์ž๊ฐ€ ์–ธ๊ธ‰ํ•œ `history` ๊ธฐ๋Šฅ์€ Git ์ด๋ ฅ์ƒ 2026-04-07 13:44:06 UTC ์ปค๋ฐ‹(`plan(history)`)์— ์ƒ์„ฑ๋๊ณ , 2026-04-08์—๋Š” `play`/plan ์ƒ์„ธ/์ž๋™ํ™” ๋ณด๊ฐ• ์ž‘์—…์ด ์ง‘์ค‘๋์Šต๋‹ˆ๋‹ค. +- ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜์ด ๋น„์–ด ์žˆ๊ณ  ์†Œ์Šค ๋ทฐ์–ด์˜ diff ๋ผ๋ฒจ๋„ ์ผ๊ด€๋˜์ง€ ์•Š์•„, ์ฆ์  ํ™•์ธ ํ๋ฆ„์ด ํ•œ ๋ฒˆ์— ์ฝํžˆ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- 2026-04-08 ๋ฌธ์„œ๋Š” placeholder ํ…œํ”Œ๋ฆฟ์ด ์•„๋‹ˆ๋ผ ์‹ค์ œ ์ปค๋ฐ‹ ์ฆ์  ๊ธฐ์ค€์œผ๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- `Play`๋Š” `Test`์™€ `layout` ๋‘ ์ถ•์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ณ , ๋ ˆ์ด์•„์›ƒ ์‹คํ—˜์€ ์ „์šฉ playground์—์„œ ํ™•์žฅํ•ฉ๋‹ˆ๋‹ค. +- Plan ์†Œ์Šค ๋ทฐ์–ด ํƒญ ๋ช…์นญ์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ฐ”๋กœ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๊ฒŒ `์ž‘์—…๋ž€ / ์ „์ฒด์†Œ์Šค / diff`๋กœ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- ์ž๋™ํ™” ์ž‘์—… ๋ธŒ๋žœ์น˜ base๋Š” `main`, ์ž๋™ worklog ์ •๋ฆฌ๋Š” `hotfix/*` ํ๋ฆ„์„ ๋”ฐ๋ฆ…๋‹ˆ๋‹ค. +- 2026-04-08 ๋Œ€ํ‘œ ์Šคํฌ๋ฆฐ์ƒท์€ `Play > layout` ํ™”๋ฉด์œผ๋กœ ๊ณ ์ •ํ•ฉ๋‹ˆ๋‹ค. + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- `src/app/main/MainHeader.tsx`, `src/app/main/MainView.tsx`, `src/app/main/MainContent.tsx`, `src/app/main/types.ts`, `src/views/play/TestView.tsx`์—์„œ `Play` ๋ฉ”๋‰ด์™€ `Play > Test` ํ™”๋ฉด์„ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/views/play/LayoutPlaygroundView.tsx`์™€ `src/styles.css`์—์„œ ๋ ˆ์ด์•„์›ƒ playground๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ์ดํ›„ ๊ฐ™์€ ๋‚  ์žฌ๊ท€ ๋ถ„ํ• ๊ณผ ์ดˆ๊ธฐ ๋นˆ ์ƒํƒœ ์ง„์ž…๊นŒ์ง€ ๋‘ ์ฐจ๋ก€ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/features/planBoard/PlanBoardPage.tsx`์—์„œ ๊ธด ์ด์Šˆ ๋ณธ๋ฌธ ์ ‘ํž˜ ์ฒ˜๋ฆฌ์™€ ์†Œ์Šค ์ž‘์—… ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฝํ—˜์„ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/App.tsx`, `src/app/main/ReleasePendingMainModal.tsx`, `src/styles.css`์—์„œ `release` ์ ‘์† ์‹œ `main` ๋ฏธ๋ฐ˜์˜ ํ•ญ๋ชฉ ์•ˆ๋‚ด ๋ชจ๋‹ฌ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `scripts/run-plan-codex-once.mjs`, `etc/servers/work-server/src/services/git-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `etc/servers/work-server/src/services/plan-service.ts`์—์„œ ์ž๋™ํ™” ๋ธŒ๋žœ์น˜/์ •๋ฆฌ ์ •์ฑ…์„ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ๋ฌธ์„œ ๋ˆ„๋ฝ ๋ณด์™„์œผ๋กœ `docs/worklogs/2026-04-08.md`๋ฅผ ์‹ค์ œ ๊ธฐ๋ก ๊ธฐ์ค€์œผ๋กœ ์žฌ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. +- `scripts/capture-feature-screenshot.mjs`์— `play-layout` ํ”„๋ฆฌ์…‹์„ ์ถ”๊ฐ€ํ•ด ์˜ค๋Š˜์ž ๋Œ€ํ‘œ ํ™”๋ฉด์„ ๋‹ค์‹œ ์บก์ฒ˜ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/previewer/CodexDiffPreviewer.tsx`์—์„œ ์†Œ์Šค ๋ทฐ์–ด ํƒญ ๋ผ๋ฒจ์„ `์ „์ฒด์†Œ์Šค / diff`๋กœ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +## ์Šคํฌ๋ฆฐ์ƒท + +![feature-play-layout](../assets/worklogs/2026-04-08/feature-play-layout.png) + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `src/app/main/MainView.tsx` + +- ์ƒ๋‹จ ๋ฉ”๋‰ด ์ฒด๊ณ„๋ฅผ `Docs / Plan / Play` ์ค‘์‹ฌ์œผ๋กœ ์žฌ์ •๋ฆฌํ•˜๊ณ  `Play > Test`, `Play > layout` ํƒ์ƒ‰์„ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๊ฒ€์ƒ‰ ์ปค๋งจ๋“œ์™€ ์ดˆ๊ธฐ URL ํŒŒ๋ผ๋ฏธํ„ฐ๋„ `playSection` ๊ธฐ์ค€์œผ๋กœ ํ•จ๊ป˜ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +```diff +const PLAY_BASE_OPEN_KEYS = ['play-group', 'play-test-group', 'play-layout-group'] as const; +... ++ id: 'page:play:test', ++ keywords: ['play', 'test', 'ํ…Œ์ŠคํŠธ', 'TEST๋ฉ”๋‰ด'], ++ }, ++ { ++ id: 'page:play:layout', ++ keywords: ['play', 'layout', 'flex', 'split', '๋ถ„ํ• ', '๋ ˆ์ด์•„์›ƒ', 'preview'], +``` + +### ํŒŒ์ผ 2: `src/views/play/LayoutPlaygroundView.tsx` + +- `play > layout` ํ™”๋ฉด์„ ์ถ”๊ฐ€ํ•˜๊ณ , ๊ฐ™์€ ๋‚  ์žฌ๊ท€ ๋ถ„ํ• ๊ณผ ๋นˆ ์ƒํƒœ ์‹œ์ž‘ ํ”Œ๋กœ์šฐ๊นŒ์ง€ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์ƒํ•˜/์ขŒ์šฐ ์‹œ์ž‘, `px/%` ๋‹จ์œ„, ์ตœ์†Œ ํฌ๊ธฐ, ๋ฆฌ์‚ฌ์ด์ฆˆ ํ—ˆ์šฉ ์—ฌ๋ถ€๋ฅผ ํ™”๋ฉด์—์„œ ๋ฐ”๋กœ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/views/play/LayoutPlaygroundView.tsx b/src/views/play/LayoutPlaygroundView.tsx ++const DEFAULT_LAYOUT_DIRECTION = 'row'; ++const EMPTY_LAYOUT: LayoutNode | null = null; +... ++ ++ +... ++ ++ +``` + +### ํŒŒ์ผ 3: `src/app/main/ReleasePendingMainModal.tsx` + +- `release` ํ™˜๊ฒฝ์—์„œ ์•„์ง `main`์— ๋ฐ˜์˜๋˜์ง€ ์•Š์€ ์ž‘์—…๋งŒ ๋ชจ์•„ ๋ณด์—ฌ์ฃผ๋Š” ์ „์šฉ ๋ชจ๋‹ฌ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ`, `main๋ฐ˜์˜๋Œ€๊ธฐ/์ค‘/์‹คํŒจ` ์ƒํƒœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์š”์•ฝ๊ณผ ํ˜„ํ™ฉ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. + +```diff +diff --git a/src/app/main/ReleasePendingMainModal.tsx b/src/app/main/ReleasePendingMainModal.tsx ++export function ReleasePendingMainModal() { ++ // release ์„œ๋ฒ„์—์„œ main ๋ฏธ๋ฐ˜์˜ ํ•ญ๋ชฉ๋งŒ ์กฐํšŒ ++} +``` + +### ํŒŒ์ผ 4: `scripts/run-plan-codex-once.mjs` + +- `.auto_codex` ์ž‘์—… ๋ธŒ๋žœ์น˜์˜ base๋ฅผ `release`๊ฐ€ ์•„๋‹ˆ๋ผ `main`์œผ๋กœ ๋งž์ท„์Šต๋‹ˆ๋‹ค. +- ๊ฐ™์€ ๋‚  ์ž๋™ worklog ์ชฝ์€ `hotfix/*` ํ๋ฆ„์„ ๋”ฐ๋ฅด๋„๋ก ์„œ๋ฒ„ ๋กœ์ง๋„ ํ•จ๊ป˜ ๋ณด๊ฐ•๋์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs +-const baseBranch = 'release'; ++const baseBranch = 'main'; +``` + +### ํŒŒ์ผ 5: `src/features/planBoard/PlanBoardPage.tsx` + +- ๊ธด `issue`/`error` ๋ณธ๋ฌธ์€ ๊ธฐ๋ณธ ์ ‘ํž˜์œผ๋กœ ๋ฐ”๊พธ๊ณ , ์ด๋ฒˆ ๋ณด์™„์—์„œ ์†Œ์Šค ๋ทฐ์–ด ํƒญ ๋ช…์นญ๋„ `์ž‘์—…๋ž€ / ์ „์ฒด์†Œ์Šค / diff`๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/features/planBoard/PlanBoardPage.tsx b/src/features/planBoard/PlanBoardPage.tsx +- label: '์š”์•ฝ', ++ label: '์ž‘์—…๋ž€', +- label: '์ „์ฒด ์†Œ์Šค', ++ label: '์ „์ฒด์†Œ์Šค', +- label: 'Diff', ++ label: 'diff', +``` + +### ํŒŒ์ผ 6: `src/components/previewer/CodexDiffPreviewer.tsx` + +- worklog ์†Œ์Šค ํƒญ๊ณผ Plan ์†Œ์Šค ๋ทฐ์–ด์˜ ๋ชจ๋“œ ๋ผ๋ฒจ์„ `์ „์ฒด์†Œ์Šค / diff`๋กœ ํ†ต์ผํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๊ธฐ์กด `Raw Diff` ํ‘œ๊ธฐ๋ฅผ `diff`๋กœ ๋ฐ”๊ฟ” ์‚ฌ์šฉ์ž๊ฐ€ ํƒญ ๋ชฉ์ ์„ ๋ฐ”๋กœ ์ฝ์„ ์ˆ˜ ์žˆ๊ฒŒ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/src/components/previewer/CodexDiffPreviewer.tsx b/src/components/previewer/CodexDiffPreviewer.tsx +- { label: '์ „์ฒด ์†Œ์Šค', value: 'source' }, +- { label: 'Raw Diff', value: 'diff' }, ++ { label: '์ „์ฒด์†Œ์Šค', value: 'source' }, ++ { label: 'diff', value: 'diff' }, +``` + +### ํŒŒ์ผ 7: `scripts/capture-feature-screenshot.mjs` + +- `Play > layout` ํ™”๋ฉด๋„ ๊ธฐ์กด worklog ์บก์ฒ˜ ํ๋ฆ„์œผ๋กœ ๋‹ค์‹œ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฒŒ ํ”„๋ฆฌ์…‹์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜์ž ๋ˆ„๋ฝ ์Šคํฌ๋ฆฐ์ƒท์€ ์ด ํ”„๋ฆฌ์…‹์œผ๋กœ `feature-play-layout.png`๋ฅผ ์ƒ์„ฑํ•ด ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +diff --git a/scripts/capture-feature-screenshot.mjs b/scripts/capture-feature-screenshot.mjs ++ 'play-layout': { ++ topMenu: 'play', ++ screenshotFileName: 'feature-play-layout.png', ++ targetSelector: '.app-main-card', ++ query: { playSection: 'layout' }, ++ }, +``` + +### ํŒŒ์ผ 8: `docs/assets/worklogs/2026-04-08/feature-play-layout.png` + +- `Play > layout` ๋Œ€ํ‘œ ํ™”๋ฉด์„ ๋‹ค์‹œ ์บก์ฒ˜ํ•ด ์˜ค๋Š˜์ž ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜๊ณผ ๋ฐ”๋กœ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. +- 2026-04-08 ์ž‘์—…์ผ์ง€์—์„œ ๋น„์–ด ์žˆ๋˜ ์‹œ๊ฐ ์ฆ์ ์„ ์ฑ„์šฐ๋Š” ์‹ค์ œ ์‚ฐ์ถœ๋ฌผ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค. + +```diff +Binary file added: docs/assets/worklogs/2026-04-08/feature-play-layout.png +``` + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-08 00:00' --until='2026-04-08 23:59:59' --stat --oneline --decorate +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-08 00:00' --until='2026-04-08 23:59:59' --name-status --format='commit %H%nAuthor: %an%nDate: %ad%nSubject: %s' --date=iso +git -c safe.directory=/workspace/auto_codex/repo log --follow --name-status -- src/features/history/HistoryPage.tsx +npm run dev -- --host 127.0.0.1 --port 4173 +CAPTURE_BASE_URL=http://127.0.0.1:4173 node scripts/capture-feature-screenshot.mjs play-layout 2026-04-08 +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, Git ๊ธฐ๋ก ๊ธฐ์ค€) + +- M src/app/main/MainHeader.tsx +- M src/app/main/MainContent.tsx +- M src/app/main/MainSidebar.tsx +- M src/app/main/MainView.tsx +- M src/app/main/types.ts +- M src/app/main/clientIdentity.ts +- M src/App.tsx +- A src/app/main/ReleasePendingMainModal.tsx +- A src/views/play/TestView.tsx +- A src/views/play/LayoutPlaygroundView.tsx +- M src/features/planBoard/PlanBoardPage.tsx +- M src/styles.css +- M src/store/appStore/types/index.ts +- M scripts/run-plan-codex-once.mjs +- M etc/servers/work-server/src/services/git-service.ts +- A etc/servers/work-server/src/services/git-service.test.ts +- M etc/servers/work-server/src/services/plan-service.ts +- M etc/servers/work-server/src/services/plan-service.test.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M AGENTS.md +- A docs/worklogs/2026-04-08.md +- M src/components/previewer/CodexDiffPreviewer.tsx +- M scripts/capture-feature-screenshot.mjs +- A docs/assets/worklogs/2026-04-08/feature-play-layout.png diff --git a/docs/worklogs/2026-04-09.md b/docs/worklogs/2026-04-09.md new file mode 100755 index 0000000..2e6b494 --- /dev/null +++ b/docs/worklogs/2026-04-09.md @@ -0,0 +1,446 @@ +# 2026-04-09 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- `Play > Layout`์„ ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ์„ ํƒํ˜• ๋ ˆ์ด์•„์›ƒ ํŽธ์ง‘๊ธฐ๋กœ ํ™•์žฅํ•˜๊ณ , ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ๋ชฉ๋ก/์ƒ์„ธ ํ๋ฆ„์„ ์—ฌ๋Ÿฌ ์ฐจ๋ก€ ๋‹ค๋“ฌ์—ˆ์Šต๋‹ˆ๋‹ค. +- ๋ ˆ์ด์•„์›ƒ ์ €์žฅ์†Œ๋ฅผ ๋ธŒ๋ผ์šฐ์ € `IndexedDB`์—์„œ `work-db` API ๊ธฐ๋ฐ˜ ์„œ๋ฒ„ ์ €์žฅ ๋ฐฉ์‹์œผ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `Window UI` ๊ฒ€์ƒ‰ ์ค‘๋ณต ์—ด๋ฆผ, ๋ชจ๋ฐ”์ผ ๋ฆฌ์‚ฌ์ด์ฆˆ hit area, ๋”๋ธ”ํƒญ/๋”๋ธ”ํด๋ฆญ ํ™•์žฅ, ์Šคํฌ๋กค ์ฒ˜๋ฆฌ ๋ฌธ์ œ๋ฅผ ์ˆœ์ฐจ์ ์œผ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `EmbeddedMapUI`, `GPS Sample Widget`, ์„œ๋ฒ„ ๊ฒฝ์œ  ํ‘ธ์‹œ ์•Œ๋ฆผ ํ๋ฆ„์„ ์ถ”๊ฐ€ํ•ด GPS ๊ฑฐ์ /๋ฐ˜๊ฒฝ/์•Œ๋ฆผ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์•ฑ ์•ˆ์—์„œ ์ง์ ‘ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. +- `Text Memo Widget`์„ ์‹ ๊ทœ ์ถ”๊ฐ€ํ•˜๊ณ , ์ด์–ด์„œ iOS ๋ฉ”๋ชจ ๋А๋‚Œ์˜ ์‹œํŠธํ˜• UI๋กœ ์žฌ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. +- `Plan Board`์— ๋กœ์ปฌ ํŽ˜์ด์ง€๋„ค์ด์…˜, `release ๋ฐ˜์˜ ์ƒํƒœ`/`์ž๋™ํ™” ์‹คํŒจ` ๋ฐ”๋กœ๊ฐ€๊ธฐ, `main` ์ผ๊ด„ ๋ฐ˜์˜ ํ๋ฆ„์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์•ฑ ์ดˆ๊ธฐ 1.9์ดˆ ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด, ์ œ์Šค์ฒ˜ ๋‹จ์ถ•ํ‚ค ์„ค์ •, `release ๋ฐ˜์˜ ์ƒํƒœ` ๋ฉ”๋‰ด/๋ชจ๋‹ฌ, Git flow ์•ˆ์ „ ๊ทœ์น™ ๋ฌธ์„œ ๋ณด๊ฐ•๊นŒ์ง€ ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜์ž ์ž‘์—… ์ฆ์ ์„ ์œ„ํ•ด ์ „์ฒด ํ™”๋ฉด 1์žฅ๊ณผ ์œ„์ ฏ ๋‹จ์œ„ ๋ถ€๋ถ„ ์บก์ฒ˜ 1์žฅ์„ `docs/assets/worklogs/2026-04-09/`์— ์ €์žฅํ•˜๊ณ  ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- `Play > Layout` ์ €์žฅ๋ณธ์„ ํด๋ผ์ด์–ธํŠธ ๋กœ์ปฌ ์ €์žฅ์†Œ๋งŒ์œผ๋กœ ๊ด€๋ฆฌํ•˜๋˜ ์ƒํƒœ๋ผ ์ž‘์—… ํ™˜๊ฒฝ์ด ๋ฐ”๋€Œ๋ฉด ๋ ˆ์ด์•„์›ƒ์ด ๋Š๊ฒผ์Šต๋‹ˆ๋‹ค. +- `src/views/play/layoutStorage.ts`๋ฅผ `work-db` API ํ˜ธ์ถœ ๊ตฌ์กฐ๋กœ ์ „ํ™˜ํ•˜๊ณ , ์ธ์ฆ ํ† ํฐ/ํด๋ผ์ด์–ธํŠธ ID ํ—ค๋”๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด๋„๋ก ๋ฐ”๊ฟ” ์ €์žฅ๋ณธ์„ ์„œ๋ฒ„ ๊ธฐ์ค€์œผ๋กœ ์œ ์ง€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `Window UI` ๊ฒ€์ƒ‰/์—ด๊ธฐ ํ๋ฆ„์—์„œ ๊ฐ™์€ ํ•ญ๋ชฉ์ด ์งง์€ ์‹œ๊ฐ„ ์•ˆ์— 2๊ฐœ์”ฉ ์—ด๋ฆฌ๊ฑฐ๋‚˜, ์ „์ฒด ๋ถ€๋ชจ ๊ธฐ์ค€์œผ๋กœ ๊ณผ๋„ํ•˜๊ฒŒ ํ™•์žฅ๋˜๋Š” ์žฌ๋ฐœ ์ด์Šˆ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- `SearchLayerContext`, `SearchCommandModal`, `WindowUI`๋ฅผ ํ•จ๊ป˜ ์ˆ˜์ •ํ•ด ์„ ํƒ ์ž ๊ธˆ, 500ms ์ค‘๋ณต ์ฐจ๋‹จ, ๋ณด์ด๋Š” viewport ๊ธฐ์ค€ ํ™•์žฅ ๋กœ์ง์œผ๋กœ ์•ˆ์ •ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์ง€๋„ ๋ฐ˜๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด๋Š” ์ดˆ๊ธฐ์— ์ขŒํ‘œ ๊ธฐ๋ฐ˜ ๋ Œ๋”๊ฐ€ ์•„๋‹ˆ๋ผ CSS ๊ณ ์ • ์˜ค๋ฒ„๋ ˆ์ด ์„ฑ๊ฒฉ์ด ๊ฐ•ํ•ด ์ง€๋„๋ฅผ ์›€์ง์ผ ๋•Œ ์˜๋ฏธ๊ฐ€ ๊นจ์กŒ์Šต๋‹ˆ๋‹ค. +- `EmbeddedMapUI`๋ฅผ ์‹ค์ œ ์ขŒํ‘œ/๊ฒฝ๊ณ„ ๊ณ„์‚ฐ ๊ธฐ๋ฐ˜ OSM iframe ์˜ค๋ฒ„๋ ˆ์ด๋กœ ๋ฐ”๊พธ๊ณ , GPS ์œ„์ ฏ์—์„œ ์„ ํƒ ๊ฑฐ์ ๊ณผ ํ˜„์žฌ ์œ„์น˜๋ฅผ ํ•จ๊ป˜ ํ‘œ์‹œํ•˜๋„๋ก ๋งž์ท„์Šต๋‹ˆ๋‹ค. +- ์ž๋™ํ™” ์ชฝ์€ `main` ๋ฐ˜์˜์ด ๋‹จ๊ฑด ๊ธฐ์ค€์œผ๋กœ๋งŒ ์›€์ง์—ฌ `release`์— ์Œ“์ธ ํ•ญ๋ชฉ์„ ํ•œ ๋ฒˆ์— ๋„˜๊ธฐ๊ธฐ ์–ด๋ ค์› ์Šต๋‹ˆ๋‹ค. +- `plan-service.ts`, `plan-worker.ts`์—์„œ `release_target` ๊ธฐ์ค€ `main` ์ผ๊ด„ ๋ฐ˜์˜/์žฌ์‹œ๋„ ํ๋ฆ„์œผ๋กœ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- `Play > Layout`์˜ ๋Œ€ํ‘œ ๋ช…์นญ์€ `Layout Editor`๋กœ ํ†ต์ผํ•˜๊ณ , ์ €์žฅ๋œ ๋ ˆ์ด์•„์›ƒ์€ ๋ณ„๋„ ๋ฉ”๋‰ด ์—”ํŠธ๋ฆฌ๋กœ ๋…ธ์ถœํ•ฉ๋‹ˆ๋‹ค. +- ๋ ˆ์ด์•„์›ƒ/ํ”Œ๋ ˆ์ด๊ทธ๋ผ์šด๋“œ ์ €์žฅ์€ ๋ธŒ๋ผ์šฐ์ € ๋กœ์ปฌ ์ƒํƒœ๋ณด๋‹ค `work-db` ์„œ๋ฒ„ ์ €์žฅ์„ ์šฐ์„  ๊ธฐ์ค€์œผ๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- `Plan` ์ƒ๋‹จ/์„ค์ • ์˜์—ญ์˜ `release ๋ฐ˜์˜ ์ƒํƒœ`, `์ž๋™ํ™” ์‹คํŒจ`๋Š” ๋ณ„๋„ ๋น ๋ฅธ ์ง„์ž…์ ์œผ๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- `hotfix`์™€ `feature` ํ›„์† ์ž‘์—…์€ ํ•ญ์ƒ ์ƒˆ ๋ธŒ๋žœ์น˜์—์„œ ๋‹ค์‹œ ์‹œ์ž‘ํ•œ๋‹ค๋Š” Git ์•ˆ์ „ ๊ทœ์น™์„ ๋ฌธ์„œ์— ๋ช…์‹œํ•ฉ๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ๋Œ€ํ‘œ ์Šคํฌ๋ฆฐ์ƒท์€ `Play > Layout`, ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ ์‹ค์ œ ์œ„์ ฏ ๋‹จ์œ„๋กœ ํ™•์ธ ๊ฐ€๋Šฅํ•œ `GPS Sample Widget` ์นด๋“œ๋กœ ๋‚จ๊น๋‹ˆ๋‹ค. + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- `src/views/play/LayoutPlaygroundView.tsx`์—์„œ ์„น์…˜๋ณ„ `showHideAction`, ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ, preview-only ๋ Œ๋”, ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ๋ชฉ๋ก/์ƒ์„ธ, ์ „์ฒด๋ณด๊ธฐ/๋นˆ ์ƒํƒœ ํ”Œ๋กœ์šฐ๋ฅผ ๋ˆ„์  ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/views/play/layoutStorage.ts`์—์„œ `play_layouts` ํ…Œ์ด๋ธ”์„ ๋‹ค๋ฃจ๋Š” ์„œ๋ฒ„ API ์ €์žฅ์†Œ๋กœ ์ „ํ™˜ํ•˜๊ณ , fallback URL๊ณผ timeout ์ฒ˜๋ฆฌ๊นŒ์ง€ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/search/SearchCommandModal.tsx`, `src/layer/search/context/SearchLayerContext.tsx`, `src/components/window/WindowUI.tsx`, `src/components/window/WindowUI.css`์—์„œ ๊ฒ€์ƒ‰ ์„ ํƒ ์ค‘๋ณต, ๋ชจ๋ฐ”์ผ hit area, ๋ณด์ด๋Š” ํ™”๋ฉด ๊ธฐ์ค€ ํ™•์žฅ, ๋‹ซ๊ธฐ ์•ก์…˜์„ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/components/embeddedMap/*`, `src/layer/gps/*`, `src/widgets/gps-sample-card/*`, `etc/servers/work-server/src/routes/notification.ts`, `src/app/main/notificationApi.ts`์—์„œ ์ง€๋„ ๋‚ด์žฅ UI, GPS ๋ ˆ์ด์–ด, ์›นํ‘ธ์‹œ ์•Œ๋ฆผ ๊ฒฝ๋กœ๋ฅผ ๊ตฌ์ถ•ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/widgets/text-memo-widget/*`, `src/widgets/core/WidgetShell.tsx`, `src/widgets/registry.ts`, `src/index.ts`์—์„œ ๋ฉ”๋ชจ ์œ„์ ฏ์„ ์ถ”๊ฐ€ํ•˜๊ณ  ์œ„์ ฏ ์นด๋“œ ๋ž˜ํผ ์˜ต์…˜๊ณผ ์ƒ˜ํ”Œ ์—ฐ๊ฒฐ์„ ๋งˆ์ณค์Šต๋‹ˆ๋‹ค. +- `src/features/planBoard/PlanBoardPage.tsx`, `src/features/planBoard/quickFilters.ts`, `src/app/main/MainHeader.tsx`, `src/app/main/MainView.tsx`, `src/app/main/MainContent.tsx`์—์„œ plan ๋น ๋ฅธ ํ•„ํ„ฐ/๋กœ์ปฌ ํŒจ์ด์ง•/์œˆ๋„์šฐ ์—ด๊ธฐ/๋ฉ”๋‰ด ์ •๋ฆฌ๋ฅผ ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `etc/servers/work-server/src/services/plan-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`์—์„œ `main` ์ผ๊ด„ ๋ฐ˜์˜ ์š”์ฒญ๊ณผ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ๋กœ์ง์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `src/App.tsx`, `src/styles.css`์— ์•ฑ ์‹œ์ž‘ ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด์™€ ๋กœ๊ทธ ์ŠคํŠธ๋ฆผ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ๋„ฃ์—ˆ์Šต๋‹ˆ๋‹ค. +- `AGENTS.md`์— `release` ๋ฐ˜์˜ ์งํ›„ ๊ฐ™์€ ๋ธŒ๋žœ์น˜์—์„œ ์ž‘์—…์„ ์ด์–ด๊ฐ€์ง€ ์•Š๋„๋ก ํ•˜๋Š” ์•ˆ์ „ ์ฒดํฌ๋ฆฌ์ŠคํŠธ์™€ ๊ผฌ์ž„ ์œ„ํ—˜ ์‹ ํ˜ธ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ์—…๋ฌด์ผ์ง€ ๋ฌธ์„œ์™€ ์Šคํฌ๋ฆฐ์ƒท ์ž์‚ฐ์€ ํ˜„์žฌ ๋‚ ์งœ ๊ธฐ์ค€ Git ์ด๋ ฅ๊ณผ ์‹ค์ œ ์บก์ฒ˜ ๊ฒฐ๊ณผ๋กœ ๋‹ค์‹œ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์Šคํฌ๋ฆฐ์ƒท + +![feature-play-layout](../assets/worklogs/2026-04-09/feature-play-layout.png) + +- `Play > Layout` ์ „์ฒด ํ™”๋ฉด์ž…๋‹ˆ๋‹ค. ์ €์žฅ ๋ ˆ์ด์•„์›ƒ/ํŽธ์ง‘/๋ถ„ํ• /์„น์…˜ ์„ ํƒ ํ๋ฆ„์„ ํ•œ ๋ฒˆ์— ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +![widget-gps-sample](../assets/worklogs/2026-04-09/widget-gps-sample.png) + +- `GPS Sample Widget` ๋ถ€๋ถ„ ์บก์ฒ˜์ž…๋‹ˆ๋‹ค. ์˜ค๋Š˜ ์ถ”๊ฐ€๋œ ์ง€๋„/๊ฑฐ์ /๋ฐ˜๊ฒฝ ์œ„์ ฏ ๋‹จ์œ„ ์ฆ์ ์ž…๋‹ˆ๋‹ค. + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `src/views/play/LayoutPlaygroundView.tsx` + +- `Layout Editor`์˜ ํ•ต์‹ฌ ํ™”๋ฉด์œผ๋กœ, ์ปดํฌ๋„ŒํŠธ/์œ„์ ฏ ๋ฐ”์ธ๋”ฉ๊ณผ ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ์ƒ์„ธ ํ”Œ๋กœ์šฐ๋ฅผ ๋Œ€๋ถ€๋ถ„ ์ด ํŒŒ์ผ์—์„œ ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { SearchCommandModal, type SearchKeywordOption } from '../../components/search'; ++import { componentSampleEntries, widgetSampleEntries } from '../../app/manifests/samples.manifest'; ++type LayoutComponentBinding = { ++ optionId: string; ++ label: string; ++ description?: string; ++ keywords: string[]; ++}; ++type LayoutPlaygroundViewProps = { ++ savedLayoutViewId?: string | null; ++ showSavedLayoutsOnly?: boolean; ++ onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void; ++}; +- title: string; +- description: string; ++ showHideAction: boolean; ++ componentBinding: LayoutComponentBinding | null; +``` + +### ํŒŒ์ผ 2: `src/views/play/layoutStorage.ts` + +- ๋ ˆ์ด์•„์›ƒ ์ €์žฅ์†Œ๋ฅผ `IndexedDB`์—์„œ `work-db` API ๊ธฐ๋ฐ˜ ์„œ๋ฒ„ ์ €์žฅ ๋ฐฉ์‹์œผ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { appendClientIdHeader } from '../../app/main/clientIdentity'; ++import { getRegisteredAccessToken } from '../../app/main/tokenAccess'; +-const DATABASE_NAME = 'play-layout-db'; +-const STORE_NAME = 'saved-layouts'; +-const DATABASE_VERSION = 1; ++const WORK_SERVER_TIMEOUT_MS = 8000; ++const PLAY_LAYOUTS_TABLE = 'play_layouts'; ++class LayoutStorageError extends Error { ++ status: number; ++} +``` + +### ํŒŒ์ผ 3: `src/components/search/SearchCommandModal.tsx` + +- ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ ์ œ๋ชฉ/์„ค๋ช… ์ปค์Šคํ„ฐ๋งˆ์ด์ง•, ๋ชจ๋ฐ”์ผ ์ถ•์•ฝ ๋ฌธ๊ตฌ, ์„ ํƒ ์ž ๊ธˆ ๋กœ์ง์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++ title?: string; ++ description?: string; ++ placeholder?: string; ++ submitHint?: string; ++ const selectionLockRef = useRef(false); ++ const [isMobileViewport, setIsMobileViewport] = useState(() => { ++ return window.innerWidth <= 768; ++ }); ++ const submitOption = (option: SearchKeywordOption | undefined) => { ++ if (!option || selectionLockRef.current) { ++ return; ++ } ++ selectionLockRef.current = true; ++ onSelectOption(option); ++ onClose(); ++ }; +``` + +### ํŒŒ์ผ 4: `src/components/window/WindowUI.tsx` + +- `Window UI`๋Š” ๋‹ซ๊ธฐ ์•ก์…˜, ๋ชจ๋ฐ”์ผ ๋ฆฌ์‚ฌ์ด์ฆˆ ๊ฐœ์„ , ๋ณด์ด๋Š” viewport ๊ธฐ์ค€ ๋”๋ธ”ํƒญ/๋”๋ธ”ํด๋ฆญ ํ™•์žฅ ๋กœ์ง์„ ๋ฐ›์•˜์Šต๋‹ˆ๋‹ค. + +```diff +-import { FullscreenExitOutlined, FullscreenOutlined, MinusOutlined } from '@ant-design/icons'; ++import { CloseOutlined, FullscreenExitOutlined, FullscreenOutlined, MinusOutlined } from '@ant-design/icons'; ++type ResizeTapRecord = { ++ direction: ResizeDirection; ++ at: number; ++ x: number; ++ y: number; ++}; ++const DOUBLE_TAP_DELAY = 320; ++const DOUBLE_TAP_MOVE_TOLERANCE = 18; ++const RESIZE_MOVE_THRESHOLD = 4; ++function getVisibleParentBounds(element: HTMLDivElement | null) { ++ const viewportWidth = window.visualViewport?.width ?? window.innerWidth; ++} +``` + +### ํŒŒ์ผ 5: `src/components/embeddedMap/EmbeddedMapUI.tsx` + +- ์ƒˆ ๋‚ด์žฅ ์ง€๋„ UI๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , ์ขŒํ‘œ/๋ฐ˜๊ฒฝ/๋ณด์กฐ ๋งˆ์ปค๋ฅผ OSM iframe ์œ„์— ์˜ค๋ฒ„๋ ˆ์ด๋กœ ํ‘œ์‹œํ•˜๋„๋ก ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++export type EmbeddedMapUIProps = { ++ latitude: number; ++ longitude: number; ++ radiusMeters?: number; ++ lockViewport?: boolean; ++ secondaryMarker?: { ++ latitude: number; ++ longitude: number; ++ label?: string; ++ } | null; ++ overlay?: ReactNode; ++}; ++function createEmbedUrl(bounds: Bounds, latitude: number, longitude: number) { ++ return `https://www.openstreetmap.org/export/embed.html?...`; ++} +``` + +### ํŒŒ์ผ 6: `src/widgets/gps-sample-card/GpsSampleWidget.tsx` + +- GPS on/off, ํ˜„์žฌ ์ขŒํ‘œ, ๊ฑฐ์  ์ €์žฅ, ๋ฐ˜๊ฒฝ, In/Out ํ‘ธ์‹œ ์•Œ๋ฆผ, ์ง€๋„ ํ‘œ์‹œ๋ฅผ ํ•œ ์œ„์ ฏ์œผ๋กœ ๋ฌถ์—ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { ++ AimOutlined, ++ BellOutlined, ++ EnvironmentOutlined, ++ DeleteOutlined, ++ RadarChartOutlined, ++} from '@ant-design/icons'; ++import { EmbeddedMapUI } from '../../components/embeddedMap'; ++import { useGpsLayer } from '../../layer'; ++const selectedAnchor = anchors.find((anchor) => anchor.id === selectedAnchorId) ?? null; ++const distanceToSelectedAnchor = ++ selectedAnchor && currentPosition ++ ? calculateDistanceMeters(...) ++ : null; +``` + +### ํŒŒ์ผ 7: `src/widgets/text-memo-widget/TextMemoWidget.tsx` + +- `Text Memo Widget`์„ ์‹ ๊ทœ ์ถ”๊ฐ€ํ•˜๊ณ  ์ตœ๊ทผ 6๊ฐœ ๋ฉ”๋ชจ ์ €์žฅ/๋ถˆ๋Ÿฌ์˜ค๊ธฐ/์‚ญ์ œ์™€ ์‹œํŠธํ˜• ํŽธ์ง‘ UI๋ฅผ ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++const STORAGE_KEY = 'ai-code-app:text-memo-widget'; ++const MAX_SAVED_NOTES = 6; ++type SavedNote = { ++ id: string; ++ body: string; ++ createdAt: string; ++}; ++export const TextMemoWidget = forwardRef(function TextMemoWidget( ++ { cardWrapper }, ++ ref, ++) { +``` + +### ํŒŒ์ผ 8: `src/features/planBoard/PlanBoardPage.tsx` + +- plan ๋ชฉ๋ก ๋กœ์ปฌ ํŒจ์ด์ง•, ๋น ๋ฅธ ํ•„ํ„ฐ, ์ตœ๋Œ€ํ™” ๋’ค ์•„์ด์ฝ˜-only ๋Œ์•„๊ฐ€๊ธฐ, ๊ธด ๋ณธ๋ฌธ ์ ‘ํž˜ ํ๋ฆ„์„ ์˜ค๋Š˜ ๊ธฐ์ค€์œผ๋กœ ๋ˆ„์  ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++ const [currentPage, setCurrentPage] = useState(1); ++ const pageSize = 10; ++ const pagedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize); ++ useEffect(() => { ++ setCurrentPage(1); ++ }, [filter, searchQuery]); +- label: '์š”์•ฝ', ++ label: '์ž‘์—…๋ž€', +``` + +### ํŒŒ์ผ 9: `src/app/main/MainHeader.tsx` + +- ์„ค์ • ๋ฉ”๋‰ด์— `release ์ƒํƒœ ์ž‘์—…`, `์ž๋™ํ™” ์‹คํŒจ` ๋ฐ”๋กœ๊ฐ€๊ธฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ  ํ˜„์žฌ ๊ฑด์ˆ˜๋ฅผ ๊ฐ™์ด ๋ณด์—ฌ์ฃผ๋„๋ก ํ™•์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { isAutomationFailedItem, isReleasePendingMainItem } from '../../features/planBoard/quickFilters'; ++const [planShortcutCounts, setPlanShortcutCounts] = useState({ ++ releasePendingMain: 0, ++ automationFailed: 0, ++}); ++void fetchPlanItems('all') ++ .then((items) => { ++ setPlanShortcutCounts({ ++ releasePendingMain: items.filter(isReleasePendingMainItem).length, ++ automationFailed: items.filter(isAutomationFailedItem).length, ++ }); ++ }) +``` + +### ํŒŒ์ผ 10: `src/app/main/MainView.tsx` + +- ์ƒ๋‹จ/์‚ฌ์ด๋“œ ๋ฉ”๋‰ด ๊ตฌ์กฐ๋ฅผ `release ๋ฐ˜์˜ ์ƒํƒœ`, `APIs / Widgets`, `Layout Editor`, ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ๋ฉ”๋‰ด๊นŒ์ง€ ํฌ๊ด„ํ•˜๋„๋ก ์žฌ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +- 'in-progress': '์ž‘์—…์ค‘', ++ 'in-progress': '์ž๋™ํ™” ๋Œ€๊ธฐ / ์ž‘์—… ์ค‘', +- error: '์˜ค๋ฅ˜', ++ error: '์˜ค๋ฅ˜ (์ž‘์—… ์™„๋ฃŒ ์ „)', ++ release: 'release ๋ฐ˜์˜ ์ƒํƒœ', ++const PLAY_LAYOUT_RECORD_PREFIX = 'layout-record:' as const; ++function resolvePlanQuickFilterMenu(filter: PlanQuickFilter): Extract { ++ return filter === 'release-pending-main' ? 'release' : 'error'; ++} +``` + +### ํŒŒ์ผ 11: `src/app/main/MainContent.tsx` + +- ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๋ฅผ `Window UI` ์ฐฝ์œผ๋กœ ์—ฌ๋Š” ํ๋ฆ„, ์ƒ˜ํ”Œ ์ˆจ๊น€ ๋ชฉ๋ก, `Plan`/์ €์žฅ ๋ ˆ์ด์•„์›ƒ ๋ Œ๋” ๋ถ„๊ธฐ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { useMemo, useState, type ReactNode } from 'react'; ++import { WindowUI, type WindowFrame } from '../../components/window'; ++import { useSearchLayer } from '../../layer'; ++const HIDDEN_COMPONENT_IDS = ['search-command-modal', 'window-ui']; ++const { windowSelections, clearWindowSelection } = useSearchLayer(); ++const [windowFrames, setWindowFrames] = useState>({}); ++const [windowZIndexes, setWindowZIndexes] = useState>({}); +``` + +### ํŒŒ์ผ 12: `src/App.tsx` + +- ์•ฑ ์‹œ์ž‘ ์งํ›„ 1.9์ดˆ ๋™์•ˆ ๋ณด์—ฌ์ฃผ๋Š” ํ’€์Šคํฌ๋ฆฐ ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด์™€ ๋กœ๊ทธ ์ŠคํŠธ๋ฆผ UI๋ฅผ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++const INITIAL_LOADING_LOGS = [ ++ 'BOOT SEQUENCE :: app shell warmup', ++ 'CONFIG SYNC :: workspace profile applied', ++ 'SESSION LINK :: reconnecting realtime channel', ++ 'MODULE CHECK :: dashboard widgets online', ++ 'READY SIGNAL :: rendering main viewport', ++]; ++const [showInitialLoading, setShowInitialLoading] = useState(true); ++window.setTimeout(() => { ++ setShowInitialLoading(false); ++}, 1900); +``` + +### ํŒŒ์ผ 13: `etc/servers/work-server/src/services/plan-service.ts` + +- `main` ๋ฐ˜์˜ ์š”์ฒญ/ํด๋ ˆ์ž„/์™„๋ฃŒ ์ฒ˜๋ฆฌ ๋ชจ๋‘๋ฅผ `release_target` ๊ธฐ์ค€ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋กœ ๋ฐ”๊ฟจ์Šต๋‹ˆ๋‹ค. + +```diff +- isMainRetry ? 'main ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.' : 'release ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', ++ isMainRetry ? 'main ์ผ๊ด„ ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.' : 'release ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', ++ const pendingRows = await db(PLAN_TABLE) ++ .select('id') ++ .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', release_target: releaseTarget }); +- .where({ id }) ++ .whereIn('id', targetIds.length > 0 ? targetIds : [id]) ++ message: `${releaseTarget} ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€์œผ๋กœ ${Math.max(targetIds.length, 1)}๊ฑด main ์ผ๊ด„ ๋ฐ˜์˜์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.`, +``` + +### ํŒŒ์ผ 14: `AGENTS.md` + +- Git ์•ˆ์ „ ๊ทœ์น™์„ ๋ณด๊ฐ•ํ•ด `release` ๋ฐ˜์˜ ๋’ค ๊ฐ™์€ ๋ธŒ๋žœ์น˜์—์„œ ์ถ”๊ฐ€ ์ž‘์—…์„ ์ด์–ด๊ฐ€๋Š” ํŒจํ„ด์„ ๊ธˆ์ง€ํ•˜๊ณ  ์ ๊ฒ€ ์ ˆ์ฐจ๋ฅผ ๋ช…์‹œํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++* `release` ๋ฐ˜์˜ ์ƒํƒœ์—์„œ ํ˜„์žฌ ๋ธŒ๋žœ์น˜ ๊ทธ๋Œ€๋กœ ์ถ”๊ฐ€ ์ž‘์—… ์ง„ํ–‰ ++* `release` ๋ฐ˜์˜์ด ๋๋‚œ ๋’ค ์ถ”๊ฐ€ ์ˆ˜์ • ์š”์ฒญ์ด ๋“ค์–ด์˜ค๋ฉด, ๋ฐ˜๋“œ์‹œ ์ƒˆ ๋ธŒ๋žœ์น˜์—์„œ ๋‹ค์‹œ ์‹œ์ž‘ํ•œ๋‹ค ++## ์•ˆ์ „ ์ ๊ฒ€ ์ฒดํฌ๋ฆฌ์ŠคํŠธ ++1. ์ง€๊ธˆ ๋ฐ˜์˜ํ•  ๋ณ€๊ฒฝ์ด `release`์— ๋จผ์ € ๋“ค์–ด๊ฐ”๋Š”์ง€ ํ™•์ธ ++2. ์ถ”๊ฐ€ ์š”์ฒญ์ด๋ผ๋ฉด ์ƒˆ `feature/*` ๋˜๋Š” `hotfix/*` ๋ธŒ๋žœ์น˜์—์„œ ์ž‘์—… ์ค‘์ธ์ง€ ํ™•์ธ ++## ๊ผฌ์ž„ ์œ„ํ—˜ ์‹ ํ˜ธ ++* `release` ๋ฐ˜์˜ ์งํ›„ ์ถ”๊ฐ€ ์š”์ฒญ์„ ํ˜„์žฌ ๋ธŒ๋žœ์น˜์—์„œ ๋ฐ”๋กœ ์ด์–ด์„œ ์ฒ˜๋ฆฌํ•˜๋Š” ๊ฒฝ์šฐ +``` + +### ํŒŒ์ผ 15: `docs/assets/worklogs/2026-04-09/feature-play-layout.png` + +- ์˜ค๋Š˜ ๋Œ€ํ‘œ ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท์ž…๋‹ˆ๋‹ค. + +```diff +Binary file added: docs/assets/worklogs/2026-04-09/feature-play-layout.png +``` + +### ํŒŒ์ผ 16: `docs/assets/worklogs/2026-04-09/widget-gps-sample.png` + +- ์˜ค๋Š˜ ์œ„์ ฏ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์ž…๋‹ˆ๋‹ค. + +```diff +Binary file added: docs/assets/worklogs/2026-04-09/widget-gps-sample.png +``` + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-09 00:00' --until='2026-04-09 23:59:59' --stat --oneline --decorate +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-09 00:00' --until='2026-04-09 23:59:59' --name-status --format='commit %H%nAuthor: %an%nDate: %ad%nSubject: %s%n' --date=iso +git -c safe.directory=/workspace/auto_codex/repo diff --stat b70ce0c2549542703967b73aedf8d249050a40f7..HEAD +npm run dev -- --host 127.0.0.1 --port 4173 +CAPTURE_BASE_URL=http://127.0.0.1:4173 node scripts/capture-feature-screenshot.mjs play-layout 2026-04-09 +node --input-type=module # GPS Sample Widget ๋ถ€๋ถ„ ์บก์ฒ˜ +npm run build:app +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ (์ „์ฒด, ์ค‘๋ณต ์ œ๊ฑฐ, Git ๊ธฐ๋ก ๊ธฐ์ค€ + ์˜ค๋Š˜ ์—…๋ฌด์ผ์ง€ ์‚ฐ์ถœ๋ฌผ) + +- A src/components/dashboard/multiProgress/samples/BaseSample.tsx +- A src/components/dashboard/progress/samples/BaseSample.tsx +- A src/components/embeddedMap/EmbeddedMapUI.css +- A src/components/embeddedMap/EmbeddedMapUI.tsx +- A src/components/embeddedMap/index.ts +- A src/components/embeddedMap/samples/BaseSample.tsx +- A src/components/embeddedMap/samples/Sample.tsx +- A src/components/inputs/checkCombo/samples/BaseSample.tsx +- A src/components/inputs/composite/multiInput/samples/BaseSample.tsx +- A src/components/inputs/popup/samples/BaseSample.tsx +- A src/components/inputs/primitives/input/samples/BaseSample.tsx +- A src/components/inputs/select/samples/BaseSample.tsx +- A src/components/inputs/specialized/buttonEditableInput/samples/BaseSample.tsx +- A src/components/inputs/specialized/emailInput/samples/BaseSample.tsx +- A src/components/markdownPreview/samples/MarkdownPreviewCardBaseSample.tsx +- A src/components/markdownPreview/samples/MarkdownPreviewContentBaseSample.tsx +- A src/components/markdownPreview/samples/MarkdownPreviewListBaseSample.tsx +- A src/components/navigation/samples/FolderTreeNavBaseSample.tsx +- A src/components/navigation/samples/SectionMenuLayoutBaseSample.tsx +- A src/components/previewer/samples/BaseSample.tsx +- A src/components/previewer/samples/CodexDiffBaseSample.tsx +- A src/components/search/samples/BaseSample.tsx +- A src/components/status-badge/samples/BaseSample.tsx +- A src/components/window/samples/BaseSample.tsx +- A src/features/planBoard/quickFilters.ts +- A src/layer/gps/context/GpsLayerContext.tsx +- A src/layer/gps/hooks/useGpsLayer.ts +- A src/layer/gps/index.ts +- A src/layer/gps/types/index.ts +- A src/widgets/gps-sample-card/GpsSampleWidget.css +- A src/widgets/gps-sample-card/GpsSampleWidget.tsx +- A src/widgets/gps-sample-card/index.ts +- A src/widgets/gps-sample-card/samples/Sample.tsx +- A src/widgets/text-memo-widget/TextMemoWidget.css +- A src/widgets/text-memo-widget/TextMemoWidget.tsx +- A src/widgets/text-memo-widget/index.ts +- A src/widgets/text-memo-widget/samples/Sample.tsx +- M AGENTS.md +- M docs/components/window-ui.md +- M etc/servers/work-server/src/routes/notification.ts +- M etc/servers/work-server/src/services/plan-service.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M package-lock.json +- M package.json +- M src/App.tsx +- M src/app/main/MainContent.tsx +- M src/app/main/MainHeader.tsx +- M src/app/main/MainView.tsx +- M src/app/main/ReleasePendingMainModal.tsx +- M src/app/main/appConfig.ts +- M src/app/main/notificationApi.ts +- M src/app/main/types.ts +- M src/components/dashboard/multiProgress/samples/Sample.tsx +- M src/components/dashboard/progress/samples/Sample.tsx +- M src/components/embeddedMap/EmbeddedMapUI.css +- M src/components/embeddedMap/EmbeddedMapUI.tsx +- M src/components/embeddedMap/samples/Sample.tsx +- M src/components/inputs/checkCombo/samples/Sample.tsx +- M src/components/inputs/composite/multiInput/samples/Sample.tsx +- M src/components/inputs/popup/samples/Sample.tsx +- M src/components/inputs/primitives/input/samples/Sample.tsx +- M src/components/inputs/select/samples/Sample.tsx +- M src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx +- M src/components/inputs/specialized/emailInput/samples/Sample.tsx +- M src/components/previewer/samples/CodexDiffSample.tsx +- M src/components/previewer/samples/Sample.tsx +- M src/components/search/SearchCommandModal.tsx +- M src/components/status-badge/samples/Sample.tsx +- M src/components/window/WindowUI.css +- M src/components/window/WindowUI.tsx +- M src/components/window/samples/Sample.tsx +- M src/components/window/types/window.ts +- M src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx +- M src/features/planBoard/PlanBoardPage.tsx +- M src/features/planBoard/index.ts +- M src/index.ts +- M src/layer/gps/context/GpsLayerContext.tsx +- M src/layer/index.ts +- M src/layer/search/context/SearchLayerContext.tsx +- M src/layer/search/types/index.ts +- M src/main.tsx +- M src/styles.css +- M src/views/play/LayoutPlaygroundView.tsx +- M src/views/play/layoutStorage.ts +- M src/widgets/api-sample-card/ApiSampleCardWidget.tsx +- M src/widgets/api-sample-card/samples/Sample.tsx +- M src/widgets/core/WidgetShell.tsx +- M src/widgets/core/index.ts +- M src/widgets/core/types/widget.ts +- M src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx +- M src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx +- M src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx +- M src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx +- M src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx +- M src/widgets/gps-sample-card/GpsSampleWidget.tsx +- M src/widgets/gps-sample-card/samples/Sample.tsx +- M src/widgets/registry.ts +- M src/widgets/text-memo-widget/TextMemoWidget.css +- M src/widgets/text-memo-widget/TextMemoWidget.tsx +- A docs/worklogs/2026-04-09.md +- A docs/assets/worklogs/2026-04-09/feature-play-layout.png +- A docs/assets/worklogs/2026-04-09/widget-gps-sample.png diff --git a/docs/worklogs/2026-04-10.md b/docs/worklogs/2026-04-10.md new file mode 100755 index 0000000..b54bcdf --- /dev/null +++ b/docs/worklogs/2026-04-10.md @@ -0,0 +1,357 @@ +# 2026-04-10 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- `react-router-dom` ๊ธฐ๋ฐ˜ ์•ฑ ์…ธ๋กœ ๋ฉ”์ธ ํ™”๋ฉด์„ ์žฌ๊ตฌ์„ฑํ•˜๊ณ , ์ƒ๋‹จ ๋ฉ”๋‰ด์™€ ์‚ฌ์ด๋“œ๋ฐ” ์„ ํƒ์ด URL ๊ฒฝ๋กœ์™€ ์ง์ ‘ ์—ฐ๊ฒฐ๋˜๋„๋ก ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `AppShell`, `MainLayout`, `routes.tsx`๋ฅผ ์ถ”๊ฐ€ํ•ด `Docs / APIs / Plans / Chat / Play`๋ฅผ ํŽ˜์ด์ง€ ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- `MainContent`๋Š” ๊ณตํ†ต ์ฐฝ ๋ ˆ์ด์–ด์™€ ์ฝ˜ํ…์ธ  ์ปจํ…Œ์ด๋„ˆ ์—ญํ• ๋งŒ ๋‚จ๊ธฐ๊ณ , ์‹ค์ œ ํ™”๋ฉด ๋ Œ๋”๋ง์€ ๊ฐ ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ๋กœ ์ด๋™์‹œ์ผฐ์Šต๋‹ˆ๋‹ค. +- ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ์ƒ์„ธ ํ™”๋ฉด์€ `savedLayoutViewId` ์ „์šฉ fit ๋ชจ๋“œ๋กœ ๋ณด์ •ํ•ด, ์ƒ๋‹จ ๋ฐ”๋ฅผ ํฌํ•จํ•œ ์ „์ฒด ๊ตฌ์„ฑ์„ ํ•œ ํ™”๋ฉด ์•ˆ์— ๋งž์ถฐ ๋ณด์ด๋„๋ก ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +- `Text Memo Widget`์˜ ์‚ญ์ œ ๋ฒ„ํŠผ์ด ์‹ค์ œ๋กœ ๋™์ž‘ํ•˜๋„๋ก `Modal.useModal()` ๊ธฐ๋ฐ˜ ํ™•์ธ ํ”Œ๋กœ์šฐ๋ฅผ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ์ฆ์ ์šฉ์œผ๋กœ `APIs / Widgets` ์ „์ฒด ํ™”๋ฉด 1์žฅ๊ณผ `Text Memo Widget` ๋ถ€๋ถ„ ํ™”๋ฉด 1์žฅ์„ `docs/assets/worklogs/2026-04-10/`์— ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜์ž ์ž‘์—…์ผ์ง€ ๋ฌธ์„œ๋ฅผ ์ƒˆ๋กœ ์ž‘์„ฑํ•˜๊ณ , ์†Œ์Šค ํƒญ์—์„œ `์ „์ฒด์†Œ์Šค / diff` ์ „ํ™˜์— ๋Œ€์‘ํ•  ์ˆ˜ ์žˆ๋„๋ก ํŒŒ์ผ๋ณ„ raw diff ๊ทผ๊ฑฐ์™€ ์ „์ฒด ๋ณ€๊ฒฝ ํŒŒ์ผ ๋ชฉ๋ก์„ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- ๊ธฐ์กด `MainView` ํ•œ ํŒŒ์ผ์— ๋ฉ”๋‰ด ์ƒํƒœ, ๊ฒ€์ƒ‰ ์˜ต์…˜, ๋ฌธ์„œ/ํ”Œ๋žœ/์ฑ„ํŒ…/ํ”Œ๋ ˆ์ด ๋ Œ๋”๋ง์ด ๋ชจ๋‘ ์–ฝํ˜€ ์žˆ์–ด ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ํƒ์ƒ‰๊ณผ ํ™•์žฅ์ด ์–ด๋ ค์› ์Šต๋‹ˆ๋‹ค. +- `AppShell + MainLayout + pages/* + routes.tsx` ๊ตฌ์กฐ๋กœ ๋‚˜๋ˆ„๊ณ , ๋ผ์šฐํŒ… ํŒŒ์ƒ ์ƒํƒœ๋Š” `parseRoute()`์™€ `MainLayoutContext`์—์„œ๋งŒ ๊ณ„์‚ฐํ•˜๋„๋ก ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ์ „์šฉ ํ™”๋ฉด์€ ๋‚ด๋ถ€ ์ฝ˜ํ…์ธ  ๋†’์ด์™€ ์Šคํฌ๋กค ์ƒํƒœ์— ๋”ฐ๋ผ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๊ฐ€ ์ž˜๋ฆฌ๊ฑฐ๋‚˜ ๋นˆ ์—ฌ๋ฐฑ์ด ์ƒ๊ฒผ์Šต๋‹ˆ๋‹ค. +- `useLayoutEffect`, `ResizeObserver`, `MutationObserver`๋กœ ์‹ค์ œ ๋ Œ๋” ํฌ๊ธฐ๋ฅผ ๋‹ค์‹œ ์žฌ๊ณ  `savedLayoutFitScale`์„ ๊ณ„์‚ฐํ•ด fit ๋ Œ๋”๋ฅผ ์•ˆ์ •ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๋ฉ”๋ชจ ์œ„์ ฏ ์‚ญ์ œ ๋ฒ„ํŠผ์€ ์ •์  ๋ชจ๋‹ฌ ํ˜ธ์ถœ๊ณผ ์ปจํ…์ŠคํŠธ ๋ฏธ์—ฐ๊ฒฐ ์ƒํƒœ ๋•Œ๋ฌธ์— ํด๋ฆญํ•ด๋„ ํ™•์ธ์ฐฝ์ด ๋œจ์ง€ ์•Š๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- `Modal.useModal()`์„ ๋„์ž…ํ•˜๊ณ  `modalContextHolder`๋ฅผ ์œ„์ ฏ ๋ฃจํŠธ์— ๋„ฃ์–ด ์ €์žฅ ๋ฉ”๋ชจ์™€ ์ž‘์„ฑ ์ค‘ ์ดˆ์•ˆ ๋ชจ๋‘ ์‚ญ์ œ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฐ”๊ฟจ์Šต๋‹ˆ๋‹ค. +- ์ด ์ž‘์—… ํ™˜๊ฒฝ์—์„œ๋Š” Git์ด `dubious ownership`์œผ๋กœ ์ฐจ๋‹จ๋˜๊ณ  ๊ธ€๋กœ๋ฒŒ `safe.directory` ๋“ฑ๋ก๋„ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๊ธ€๋กœ๋ฒŒ ์„ค์ • ๋Œ€์‹  ๋ชจ๋“  Git ์กฐํšŒ์— `git -c safe.directory=/workspace/auto_codex/repo ...`๋ฅผ ์‚ฌ์šฉํ•ด ์ฆ์  ์ˆ˜์ง‘์„ ๊ณ„์† ์ง„ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ๋ฉ”์ธ ์•ฑ ์ง„์ž…์€ ์ƒํƒœ ๊ธฐ๋ฐ˜ ์กฐ๊ฑด ๋ถ„๊ธฐ๋ณด๋‹ค URL ๋ผ์šฐํŒ…์„ ์šฐ์„  ๊ธฐ์ค€์œผ๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- ๊ณตํ†ต ๋ ˆ์ด์•„์›ƒ ์ƒํƒœ๋Š” `MainLayoutContext`๋กœ ์ „๋‹ฌํ•˜๊ณ , ํŽ˜์ด์ง€๋ณ„ ๋ Œ๋”๋ง ์ฑ…์ž„์€ `pages/*`๋กœ ๋ถ„๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ์ƒ์„ธ ๋ทฐ๋Š” ์Šคํฌ๋กค๋ณด๋‹ค fit ์šฐ์„  ๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ทœ์น™์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +- ๋ฉ”๋ชจ ์‚ญ์ œ๋Š” ์ฆ‰์‹œ ์‚ญ์ œํ•˜์ง€ ์•Š๊ณ  ํ•ญ์ƒ ํ™•์ธ ๋ชจ๋‹ฌ์„ ๊ฑฐ์น˜๊ฒŒ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ๋Œ€ํ‘œ ์Šคํฌ๋ฆฐ์ƒท์€ `APIs / Widgets` ์ „์ฒด ํ™”๋ฉด, ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ `Text Memo Widget`์œผ๋กœ ๊ณ ์ •ํ•ฉ๋‹ˆ๋‹ค. + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ์˜ค๋Š˜ Git ์ด๋ ฅ์€ `WindowUI` ์ตœ์†Œํ™”/ํ—ค๋” ์ •๋ฆฌ, ๋ฉ”๋ชจ ์‚ญ์ œ ํ”Œ๋กœ์šฐ ๋ณต๊ตฌ, ์ €์žฅ ๋ ˆ์ด์•„์›ƒ fit ๋ชจ๋“œ ๋ณด์ •, ์•ฑ ์…ธ ๋ผ์šฐํŒ… ๋ถ„๋ฆฌ ์ˆœ์„œ๋กœ ๋ˆ„์ ๋๊ณ  ์ตœ์ข…์ ์œผ๋กœ `release -> main` ๋™๊ธฐํ™”๊นŒ์ง€ ์ง„ํ–‰๋์Šต๋‹ˆ๋‹ค. +- ๋ผ์šฐํŒ… ๊ฐœํŽธ์€ `MainView`์— ๋ชจ์—ฌ ์žˆ๋˜ ๋ฉ”๋‰ด/๋ฌธ์„œ/ํ”Œ๋žœ/์ฑ„ํŒ…/ํ”Œ๋ ˆ์ด ์กฐ๊ฑด ๋ถ„๊ธฐ๋ฅผ ๊ฑท์–ด๋‚ด๊ณ , `AppShell` ์•„๋ž˜์—์„œ `MainLayout`์ด ๊ณตํ†ต ์ƒํƒœ๋ฅผ ์ œ๊ณตํ•œ ๋’ค ๊ฐ ํŽ˜์ด์ง€๊ฐ€ ์ž๊ธฐ ํ™”๋ฉด๋งŒ ๋ Œ๋”๋งํ•˜๋„๋ก ์žฌ์กฐ์ •ํ•œ ์ž‘์—…์ž…๋‹ˆ๋‹ค. +- ๊ฒ€์ƒ‰ ์˜ต์…˜ ๋นŒ๋“œ๋„ ๊ฐ™์€ ๋ผ์šฐํŒ… ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ผ๊ฐ€๋„๋ก ์žฌ์ž‘์„ฑํ•ด์„œ, ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ๋‹จ์ˆœ ์ƒํƒœ ๋ณ€๊ฒฝ์ด ์•„๋‹ˆ๋ผ ์‹ค์ œ ๊ฒฝ๋กœ ์ „ํ™˜๊ณผ ํฌ์ปค์Šค ์ด๋™์„ ํ•จ๊ป˜ ์ˆ˜ํ–‰ํ•˜๋„๋ก ๋งž์ท„์Šต๋‹ˆ๋‹ค. +- ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ๋ณด์ •์€ ๋‹จ์ˆœ CSS ์Šค์ผ€์ผ๋ง์ด ์•„๋‹ˆ๋ผ ์‹ค์ œ ์ฝ˜ํ…์ธ  ํฌ๊ธฐ๋ฅผ ์ธก์ •ํ•ด viewport ์•ˆ์— ๋งž์ถ”๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ฐ”๊ฟจ๊ณ , ๊ด€๋ จ overflow ๊ทœ์น™๋„ ๊ฐ™์ด ์กฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๋ฉ”๋ชจ ์œ„์ ฏ์€ ์‚ญ์ œ ๋ฒ„ํŠผ ํ™œ์„ฑํ™”์™€ ํ™•์ธ ๋ชจ๋‹ฌ ํ๋ฆ„์„ ๋ถ„๋ฆฌํ•ด, ์ €์žฅ๋œ ๋ฉ”๋ชจ์™€ ์ž‘์„ฑ ์ค‘ ์ดˆ์•ˆ์„ ๊ฐ™์€ UX ์•ˆ์—์„œ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ์ž‘์—…์ผ์ง€ ์ž‘์„ฑ ๋‹จ๊ณ„์—์„œ๋Š” Git ๋กœ๊ทธ, diff, ๋นŒ๋“œ ๊ฒฐ๊ณผ, ์‹ค์ œ Playwright ์บก์ฒ˜๋ฅผ ํ•จ๊ป˜ ํ™•์ธํ•ด์„œ ๋ฌธ์„œ์™€ ์Šคํฌ๋ฆฐ์ƒท ๋งํฌ๋ฅผ ์ตœ์‹  ์ƒํƒœ๋กœ ๋งž์ท„์Šต๋‹ˆ๋‹ค. + +## ์Šคํฌ๋ฆฐ์ƒท + +![feature-apis-widgets-full](../assets/worklogs/2026-04-10/feature-apis-widgets-full.png) + +- `APIs / Widgets` ์ „์ฒด ํ™”๋ฉด ์บก์ฒ˜์ž…๋‹ˆ๋‹ค. ๋ผ์šฐํŒ… ๊ธฐ๋ฐ˜ ์•ฑ ์…ธ ์•„๋ž˜์—์„œ ์œ„์ ฏ ๊ฐค๋Ÿฌ๋ฆฌ ์ „์ฒด ๋‚ด์—ญ์ด ํ•œ ๋ฒˆ์— ๋ณด์ด๋„๋ก full-page๋กœ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + +![widget-text-memo](../assets/worklogs/2026-04-10/widget-text-memo.png) + +- `Text Memo Widget` ๋ถ€๋ถ„ ์บก์ฒ˜์ž…๋‹ˆ๋‹ค. ์˜ค๋Š˜ ๋ณต๊ตฌํ•œ ๋ฉ”๋ชจ ์ž…๋ ฅ/์‚ญ์ œ ํ๋ฆ„์„ ์œ„์ ฏ ๋‹จ์œ„๋กœ ๋ฐ”๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ณ„๋„๋กœ ๋‚จ๊ฒผ์Šต๋‹ˆ๋‹ค. + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `src/app/main/AppShell.tsx` + +- ๋ผ์šฐํ„ฐ ์—”ํŠธ๋ฆฌ์™€ ํŽ˜์ด์ง€ ๋ถ„๋ฆฌ์˜ ๊ธฐ์ค€์ ์ž…๋‹ˆ๋‹ค. + +```diff ++import { Navigate, Route, Routes } from 'react-router-dom'; ++import { MainLayout } from './layout/MainLayout'; ++import { ApisPage } from './pages/ApisPage'; ++import { ChatPage } from './pages/ChatPage'; ++import { DocsPage } from './pages/DocsPage'; ++import { PlansPage } from './pages/PlansPage'; ++import { PlayPage } from './pages/PlayPage'; ++}> ++ } /> ++ } /> ++ } /> ++ } /> ++ } /> ++ +``` + +### ํŒŒ์ผ 2: `src/app/main/layout/MainLayout.tsx` + +- ๊ธฐ์กด `MainView`์˜ ๊ฑฐ๋Œ€ํ•œ ์ƒํƒœ ์กฐํ•ฉ์„ ๋ผ์šฐํŒ… ๊ธฐ๋ฐ˜ ๊ณตํ†ต ๋ ˆ์ด์•„์›ƒ์œผ๋กœ ์˜ฎ๊ฒผ์Šต๋‹ˆ๋‹ค. + +```diff ++import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; ++function parseRoute(pathname: string) { ++ if (top === 'docs') { ... } ++ if (top === 'apis' && (first === 'components' || first === 'widgets')) { ... } ++ if (top === 'play' && first === 'layout-record' && second) { ... } ++} ++const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]); ++const layoutData = useMainLayoutData(); ++ ++ ++ ++ ++ ++ ++ +``` + +### ํŒŒ์ผ 3: `src/app/main/routes.tsx` + +- ๋ฉ”๋‰ด ๋ผ๋ฒจ, ๊ฒฝ๋กœ ๋นŒ๋”, ์‚ฌ์ด๋“œ๋ฐ” ๊ณต๊ฐœ ํƒ€์ž…์„ ํ•œ๊ณณ์— ๋ชจ์•˜์Šต๋‹ˆ๋‹ค. + +```diff ++export type TopMenuKey = 'docs' | 'apis' | 'plans' | 'chat' | 'play'; ++export type PlanSectionKey = PlanFilterStatus | 'release' | 'charts' | 'history'; ++export const DOCS_DEFAULT_FOLDER = 'worklogs'; ++export function buildDocsPath(folder = DOCS_DEFAULT_FOLDER) { ++ return `/docs/${folder}`; ++} ++export function buildPlansPath(section: PlanSectionKey = 'all') { ++ return `/plans/${section}`; ++} ++export function buildPlayMenuItems(savedLayouts: Array<{ id: string; name: string }>): MenuProps['items'] { ++ return [{ key: 'play-group', children: [...] }]; ++} +``` + +### ํŒŒ์ผ 4: `src/app/main/MainContent.tsx` + +- ๊ณตํ†ต ์ฐฝ ๋ ˆ์ด์–ด๋งŒ ๋‚จ๊ธฐ๊ณ  ํ™”๋ฉด๋ณ„ ์กฐ๊ฑด ๋ถ„๊ธฐ๋ฅผ ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ฐ€์–ด๋ƒˆ์Šต๋‹ˆ๋‹ค. + +```diff +-import { Button, Card, Layout, Modal, Space, Typography } from 'antd'; ++import { Button, Layout, Modal, Space, Typography } from 'antd'; +-import { MarkdownPreviewCard } from '../../components/markdownPreview'; ++import { useMainLayoutContext } from './layout/MainLayoutContext'; +-export function MainContent({ activeTopMenu, selectedApiMenu, selectedDocsMenu, ... }: MainContentProps) { ++export function MainContent({ contentExpanded, onToggleContentExpanded, children }: MainContentProps) { ++ const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, initialSelectedPlanId, initialSelectedWorkId } = ++ useMainLayoutContext(); +-
+- {activeTopMenu === 'docs' ? ... : activeTopMenu === 'plans' ? ... : ...} +-
++
{children}
+``` + +### ํŒŒ์ผ 5: `src/app/main/MainSidebar.tsx` + +- ์†Œ๊ฐœ ์˜์—ญ๊ณผ API ๋ฉ”๋‰ด ์„ ํƒ ํƒ€์ž…์„ ๊ณตํ†ต ๋ผ์šฐํŒ… ๊ตฌ์กฐ์— ๋งž๊ฒŒ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++ introColor, ++ introTag, ++ introDescription, +- const activeTagColor = isDocsGroup ? 'gold' : activeTopMenu === 'play' ? 'cyan' : 'green'; +- ... ++ {introTag} ++ {introDescription} +- onSelectApiMenu(key); ++ onSelectApiMenu(key as MainSidebarProps['selectedApiMenu']); +``` + +### ํŒŒ์ผ 6: `src/app/main/layout/MainLayoutContext.ts` + +- ํŽ˜์ด์ง€์™€ ๊ณตํ†ต ๋ ˆ์ด์–ด๊ฐ€ ๊ฐ™์€ ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๋„๋ก ์ปจํ…์ŠคํŠธ๋ฅผ ์‹ ์„คํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++export type MainLayoutContextValue = { ++ topMenu: TopMenuKey; ++ selectedDocsMenu: string; ++ selectedApiMenu: ApiSectionKey; ++ selectedPlanMenu: PlanSectionKey; ++ selectedChatMenu: ChatSectionKey; ++ selectedPlayMenu: PlaySidebarKey; ++ searchOptions: SearchKeywordOption[]; ++}; ++const MainLayoutContext = createContext(null); ++export function useMainLayoutContext() { ... } +``` + +### ํŒŒ์ผ 7: `src/app/main/layout/buildSearchOptions.ts` + +- ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์ƒํƒœ ๋ณ€๊ฒฝ์ด ์•„๋‹ˆ๋ผ ์‹ค์ œ ๋ผ์šฐํŒ… ์ „ํ™˜์„ ์ˆ˜ํ–‰ํ•˜๋„๋ก ์žฌ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++import { buildApisPath, buildChatPath, buildDocsPath, buildPlansPath, buildPlayPath } from '../routes'; ++onSelect: () => { ++ requestPlanQuickFilter(null); ++ navigateTo(buildApisPath('widgets')); ++ setFocusedComponentId(null); ++}, ++onSelect: () => { ++ requestPlanQuickFilter('release-pending-main'); ++ navigateTo(buildPlansPath('release')); ++ setFocusedComponentId(null); ++}, ++onSelect: () => { ++ navigateTo(buildDocsPath(document.folder)); ++ setFocusedComponentId(`doc:${document.id}`); ++ scrollToElement(`document-preview-${document.id}`); ++}, +``` + +### ํŒŒ์ผ 8: `src/app/main/pages/ApisPage.tsx` + +- APIs ํ™”๋ฉด ๋ Œ๋”๋ง ์ฑ…์ž„์„ ํŽ˜์ด์ง€ ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++export function ApisPage() { ++ const { selectedApiMenu, componentSampleEntries, widgetSampleEntries } = useMainLayoutContext(); ++ return ( ++
++ ++ {selectedApiMenu === 'components' ? ( ++ ++ ) : ( ++ ++ )} ++ ++
++ ); ++} +``` + +### ํŒŒ์ผ 9: `src/views/play/LayoutPlaygroundView.tsx` + +- ์ €์žฅ ๋ ˆ์ด์•„์›ƒ ์ „์šฉ fit ๋ชจ๋“œ์™€ ์‹ค์ œ ํฌ๊ธฐ ์ธก์ • ๋กœ์ง์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +-import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; ++import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; ++const isSavedLayoutFitMode = Boolean(savedLayoutViewId); ++const [savedLayoutFitScale, setSavedLayoutFitScale] = useState(1); ++const [savedLayoutFitSize, setSavedLayoutFitSize] = useState<{ width: number; height: number } | null>(null); ++const savedLayoutFitViewportRef = useRef(null); ++const savedLayoutFitContentRef = useRef(null); ++useLayoutEffect(() => { ++ const nextScale = Math.min(1, availableWidth / width, availableHeight / height); ++ setSavedLayoutFitScale(...); ++}, [savedLayoutViewId, selectedSavedLayoutRecord, sampleEntries.length]); ++style={{ transform: `scale(${savedLayoutFitScale})`, width: `${savedLayoutFitSize.width}px` }} ++previewSurface: isSavedLayoutFitMode, +``` + +### ํŒŒ์ผ 10: `src/styles.css` + +- fit ๋ชจ๋“œ์—์„œ overflow์™€ ์Šคํฌ๋กค๋ฐ”๋ฅผ ์–ต์ œํ•˜๋Š” ์ „์šฉ ์Šคํƒ€์ผ์„ ์ถ”๊ฐ€ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++.layout-playground__fullscreen-shell--saved-fit { ++ min-height: 0; ++ overflow: hidden; ++} ++.layout-playground__saved-fit-viewport { ++ display: flex; ++ align-items: center; ++ justify-content: center; ++ overflow: hidden; ++} ++.layout-playground__saved-detail--fit * { ++ scrollbar-width: none; ++} ++.layout-playground__saved-detail--fit *::-webkit-scrollbar { ++ width: 0; ++ height: 0; ++} +``` + +### ํŒŒ์ผ 11: `src/widgets/text-memo-widget/TextMemoWidget.tsx` + +- ์‚ญ์ œ ๋ฒ„ํŠผ ๋น„ํ™œ์„ฑ ์ƒํƒœ๋ฅผ ํ•ด์ œํ•˜๊ณ , ํ™•์ธ ๋ชจ๋‹ฌ์„ ๊ฑฐ์ณ ์‹ค์ œ ์‚ญ์ œ๋˜๋„๋ก ๋ฐ”๊ฟจ์Šต๋‹ˆ๋‹ค. + +```diff +-import { Button, Empty, Input, message } from 'antd'; ++import { Button, Empty, Input, Modal, message } from 'antd'; ++const [modalApi, modalContextHolder] = Modal.useModal(); +-const handleDelete = () => { +- void messageApi.info('์‚ญ์ œ ๊ธฐ๋Šฅ์€ ํ˜„์žฌ ๋น„ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'); +-}; ++const handleDelete = () => { ++ if (!selectedNote && !hasDraft) { ++ return; ++ } ++ void modalApi.confirm({ ++ title: isDraftOnly ? '์ž‘์„ฑ ์ค‘์ธ ๋ฉ”๋ชจ๋ฅผ ์‚ญ์ œํ• ๊นŒ์š”?' : '์„ ํƒํ•œ ๋ฉ”๋ชจ๋ฅผ ์‚ญ์ œํ• ๊นŒ์š”?', ++ onOk: () => { ... }, ++ }); ++}; ++{modalContextHolder} +-disabled ++disabled={!selectedNote && !hasDraft} +``` + +### ํŒŒ์ผ 12: `src/App.tsx`, `src/main.tsx`, `src/app/main/index.ts` + +- ์•ฑ ์ง„์ž…์ ์„ ๋ผ์šฐํ„ฐ ๊ธฐ๋ฐ˜ ์…ธ๋กœ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff +-import { MainView } from './app/main'; ++import { BrowserRouter } from 'react-router-dom'; ++import { MainView } from './app/main'; ++ ++ ++ +-export { MainView } from './MainView'; ++export { AppShell } from './AppShell'; ++export { MainView } from './MainView'; +-export function MainView() { ...legacy layout... } ++export function MainView() { ++ return ; ++} +``` + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +find docs/worklogs docs/assets/worklogs -type f | sort +grep -RIn "์ž‘์—…์ผ์ง€\|์Šคํฌ๋ฆฐ์ƒท\|์†Œ์Šค ํƒญ\|์‹คํ–‰ ์ปค๋งจ๋“œ\|์ƒ์„ธํ˜•" docs/worklogs --include='*.md' +git -c safe.directory=/workspace/auto_codex/repo status --short --branch +sed -n '1,260p' docs/worklogs/2026-04-09.md +sed -n '1,260p' docs/templates/worklog-template.md +find scripts -maxdepth 1 -type f | sort +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --stat --oneline +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-10 00:00:00' --until='2026-04-10 23:59:59' --name-only --pretty=format: | sed '/^$/d' | sort | uniq +git -c safe.directory=/workspace/auto_codex/repo diff --stat 0b105f8..HEAD -- src/app/main/MainView.tsx src/app/main/layout/MainLayout.tsx src/app/main/routes.tsx src/app/main/MainContent.tsx src/widgets/text-memo-widget/TextMemoWidget.tsx src/components/window/WindowUI.tsx src/styles.css src/views/play/LayoutPlaygroundView.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainView.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/layout/MainLayout.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/widgets/text-memo-widget/TextMemoWidget.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/routes.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/MainContent.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/views/play/LayoutPlaygroundView.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/app/main/AppShell.tsx src/app/main/MainSidebar.tsx src/app/main/layout/MainLayoutContext.ts src/app/main/layout/buildSearchOptions.ts src/app/main/layout/useMainLayoutData.ts src/app/main/pages/ApisPage.tsx src/app/main/pages/ChatPage.tsx src/app/main/pages/DocsPage.tsx src/app/main/pages/PlansPage.tsx src/main.tsx +git -c safe.directory=/workspace/auto_codex/repo diff 0b105f8..HEAD -- src/components/window/WindowUI.tsx src/components/window/WindowUI.css src/widgets/text-memo-widget/TextMemoWidget.css src/styles.css +git -c safe.directory=/workspace/auto_codex/repo diff --name-status 0b105f8..HEAD +npm run build:app +PORT=4173 node scripts/serve-app-dist.mjs +node --input-type=module <<'EOF' +import { chromium } from 'playwright'; +// APIs / Widgets full-page screenshot and text memo widget crop capture +EOF +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ + +- A docs/worklogs/2026-04-10.md +- A docs/assets/worklogs/2026-04-10/feature-apis-widgets-full.png +- A docs/assets/worklogs/2026-04-10/widget-text-memo.png +- M package-lock.json +- M package.json +- M src/App.tsx +- A src/app/main/AppShell.tsx +- M src/app/main/MainContent.tsx +- M src/app/main/MainSidebar.tsx +- M src/app/main/MainView.tsx +- M src/app/main/index.ts +- A src/app/main/layout/MainLayout.tsx +- A src/app/main/layout/MainLayoutContext.ts +- A src/app/main/layout/buildSearchOptions.ts +- A src/app/main/layout/useMainLayoutData.ts +- A src/app/main/pages/ApisPage.tsx +- A src/app/main/pages/ChatPage.tsx +- A src/app/main/pages/DocsPage.tsx +- A src/app/main/pages/PlansPage.tsx +- A src/app/main/pages/PlayPage.tsx +- A src/app/main/routes.tsx +- M src/app/main/types.ts +- M src/main.tsx +- M src/styles.css +- M src/views/play/LayoutPlaygroundView.tsx +- M src/widgets/text-memo-widget/TextMemoWidget.tsx diff --git a/docs/worklogs/2026-04-11.md b/docs/worklogs/2026-04-11.md new file mode 100755 index 0000000..5a7e776 --- /dev/null +++ b/docs/worklogs/2026-04-11.md @@ -0,0 +1,296 @@ +# 2026-04-11 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ๊ฒŒ์‹œํŒ ๊ธ€์„ ์ž๋™ํ™” ํ•ญ๋ชฉ์œผ๋กœ ์ ‘์ˆ˜ํ•˜๋Š” ํ๋ฆ„์„ ์—ด๊ณ , ์ค‘๋ณต ์ ‘์ˆ˜ ์ƒํƒœ์™€ ๊ถŒํ•œ ๊ฒ€์‚ฌ๋ฅผ ๊ฐ™์ด ๋ฌถ์–ด ๊ฒŒ์‹œํŒ์—์„œ ๋ฐ”๋กœ ํ›„์† ์ž‘์—…์œผ๋กœ ๋„˜๊ธธ ์ˆ˜ ์žˆ๊ฒŒ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๊ฒŒ์‹œํŒ ๊ธฐ๋ฐ˜ Markdown ์ž๋™ ๋“ฑ๋ก ์ž๋™ํ™”๋Š” ์„ค์ •, ์›Œ์ปค ์Šค์ผ€์ค„, ๋Œ€์ƒ ํด๋” ๊ทœ์น™์„ ํ•œ ํ๋ฆ„์œผ๋กœ ๋ฌถ๊ณ  ์ฃผ๊ธฐ ๊ณ„์‚ฐ์„ ๋ถ„ ๋‹จ์œ„ ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ๋งž์ท„์Šต๋‹ˆ๋‹ค. +- Plan ๋ฐ˜๋ณต ์š”์ฒญ์€ ๊ธฐ์กด ๋ฉ”๋ชจ ๋‚ด๋ถ€ ์˜ต์…˜์—์„œ ๋ถ„๋ฆฌํ•ด ์ „์šฉ ์Šค์ผ€์ค„ ๊ธฐ๋Šฅ์œผ๋กœ ์˜ฎ๊ธฐ๊ณ , ์Šค์ผ€์ค„ ํ™”๋ฉด๊ณผ API, ์„œ๋ฒ„ ์ €์žฅ ๊ตฌ์กฐ๋ฅผ ์ƒˆ๋กœ ์—ฐ๊ฒฐํ–ˆ์Šต๋‹ˆ๋‹ค. +- Plan ํ™”๋ฉด์€ ๋ชฉ๋ก ํ•„ํ„ฐ, ์ฒดํฌ๋ฆฌ์ŠคํŠธ, ๋ฆด๋ฆฌ์ฆˆ ์š”์•ฝ, ์ฆ์  ํƒญ, ๊ฒŒ์‹œํŒ ์—ฐ๊ฒฐ, ์ด์Šˆ/์กฐ์น˜ ๊ธฐ๋ก, ๋ณธ๋ฌธ ์ตœ๋Œ€ํ™”, ์ฝ๊ธฐ ์ „์šฉ ์ œ์–ด๋ฅผ ์ˆœ์ฐจ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. +- Plans ์ง„์ž… ๊ตฌ์กฐ๋Š” ๋ถˆํ•„์š”ํ•œ ์ค‘๊ฐ„ ํ™”๋ฉด์„ ๊ฑท์–ด๋‚ด๊ณ  `Plan / ๊ฒŒ์‹œํŒ / ์ฐจํŠธ / ์Šค์ผ€์ค„ / ํžˆ์Šคํ† ๋ฆฌ`๊ฐ€ ์ง์ ‘ ์—ฐ๊ฒฐ๋˜๋„๋ก ๋‹ค์‹œ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. +- ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๋ฌถ์Œ์œผ๋กœ `FormField`, `StateKit`, `DataListTable`์„ ์ถ”๊ฐ€ํ•ด ์ดํ›„ ํ™”๋ฉด ํ™•์žฅ์— ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ๊ธฐ๋ฐ˜์„ ํ™•๋ณดํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ์ฆ์ ์šฉ์œผ๋กœ `Plan/์ž๋™ํ™”` ์ „์ฒด ํ™”๋ฉด 1์žฅ๊ณผ ์ƒ๋‹จ ๊ฐœ์š” ์˜์—ญ 1์žฅ์„ `docs/assets/worklogs/2026-04-11/`์— ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- ๊ฒŒ์‹œํŒ ์ดˆ๊ธฐํ™”๊ฐ€ ๋™์‹œ์— ๋“ค์–ด์˜ค๋ฉด ํ…Œ์ด๋ธ”๊ณผ ์ปฌ๋Ÿผ ์ƒ์„ฑ ๊ฒฝํ•ฉ์œผ๋กœ ์ฒซ ์ง„์ž…์ด ์‹คํŒจํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- ์ƒ์„ฑ ์ „ ์กด์žฌ ์—ฌ๋ถ€๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ setup ๊ฒฝํ•ฉ์„ ํก์ˆ˜ํ•ด ์ดˆ๊ธฐ ๋กœ๋“œ ์‹คํŒจ๋ฅผ ์ค„์˜€์Šต๋‹ˆ๋‹ค. +- ๊ฒŒ์‹œํŒ ์ž๋™ ๋“ฑ๋ก์€ ๋Œ€๊ธฐ ๊ธ€์ด ์—†์„ ๋•Œ ๋‹ค์Œ ์ฃผ๊ธฐ๊นŒ์ง€ ์™„์ „ํžˆ ๋ฉˆ์ถฐ ์ถ”์ฒœ ๋ฌธ์„œ Plan์ด ๋” ์ด์ƒ ์ƒ๊ธฐ์ง€ ์•Š๋Š” ๊ตฌ๊ฐ„์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- ์›Œ์ปค๊ฐ€ ๋นˆ ํ์—์„œ๋„ ๋‹ค์Œ ์Šค์ผ€์ค„์„ ์œ ์ง€ํ•˜๋„๋ก ๋ฐ”๊พธ๊ณ , ๋ถ„/์ดˆ ๋‹จ์œ„ ํ˜ผ์šฉ ๊ณ„์‚ฐ๋„ ํ•จ๊ป˜ ๋ณด์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. +- Plan ๋ฐ˜๋ณต ์š”์ฒญ ์˜ต์…˜์„ ๋ฉ”๋ชจ ๋“ฑ๋ก ํผ ์•ˆ์— ์œ ์ง€ํ•˜๋‹ˆ ์ผ๋ฐ˜ ๋ฉ”๋ชจ ํŽธ์ง‘๊ณผ ์Šค์ผ€์ค„์„ฑ ์ž‘์—…์ด ํ•œ ํ™”๋ฉด์—์„œ ์„ž์—ฌ ํŒ๋‹จ์ด ์–ด๋ ค์› ์Šต๋‹ˆ๋‹ค. +- ๋ฐ˜๋ณต ์š”์ฒญ UI๋ฅผ ๊ฑท์–ด๋‚ด๊ณ  ์ „์šฉ ์Šค์ผ€์ค„ ํ™”๋ฉด๊ณผ ์„œ๋ฒ„ ํ…Œ์ด๋ธ”๋กœ ๋ถ„๋ฆฌํ•ด ์ฑ…์ž„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ํ–ˆ์Šต๋‹ˆ๋‹ค. +- Plan ์ƒ์„ธ๋Š” ์ž๋™ํ™” ์ ‘์ˆ˜ ํ•ญ๋ชฉ์ธ๋ฐ๋„ ์ผ๋ถ€ ์ƒํƒœ๊ฐ’์ด๋‚˜ ์ด๋ ฅ ์ž…๋ ฅ์ด ์šฐํšŒ ์ˆ˜์ •๋  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. +- ์ž ๊ธˆ ํŒ์ •์„ ๊ณตํ†ต ํ•จ์ˆ˜๋กœ ๋ชจ์œผ๊ณ , ๊ถŒํ•œ ํ† ํฐ์ด ์—†๋Š” ์‚ฌ์šฉ์ž๋Š” ์กฐ์น˜ ์ž…๋ ฅ๊ณผ ์ƒํƒœ ์•ก์…˜์ด ๋ชจ๋‘ ์ฝ๊ธฐ ์ „์šฉ์œผ๋กœ๋งŒ ๋ณด์ด๊ฒŒ ๋ง‰์•˜์Šต๋‹ˆ๋‹ค. +- ์ฆ์ /๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ „์ฒดํ™”๋ฉด์€ ๋‚ด๋ถ€ ์Šคํฌ๋กค ๋Œ€์‹  ๋ถ€๋ชจ ์Šคํฌ๋กค์ด ๋”ฐ๋ผ ์›€์ง์—ฌ ์ฝ๊ธฐ ํ๋ฆ„์ด ๊นจ์กŒ์Šต๋‹ˆ๋‹ค. +- ์ „์ฒดํ™”๋ฉด ์ง„์ž… ์‹œ `body/html` ์Šคํฌ๋กค์„ ๊ณ ์ •ํ•˜๊ณ , ๋ชจ๋‹ฌ ๋‚ด๋ถ€ ์Šคํฌ๋กค๋งŒ ์œ ์ง€ํ•˜๋„๋ก ๋ฐ”๊ฟ” ์‚ฌ์šฉ ํ๋ฆ„์„ ์•ˆ์ •ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- ๊ฒŒ์‹œํŒ์—์„œ ๋งŒ๋“ค์–ด์ง„ ์ž๋™ํ™” ์ ‘์ˆ˜ ๊ฑด์€ ๊ธฐ๋ณธ์ ์œผ๋กœ `release` ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๊ณ  `main` ์ž๋™ ๋ฐ˜์˜์€ ๋ณ„๋„ ์ •์ฑ…์ด ์—†๋Š” ํ•œ ๊บผ ๋‘ก๋‹ˆ๋‹ค. +- ๋ฐ˜๋ณต์„ฑ ์ž‘์—…์€ ์ผ๋ฐ˜ ๋ฉ”๋ชจ ์˜ต์…˜์ด ์•„๋‹ˆ๋ผ ์ „์šฉ ์Šค์ผ€์ค„ ๋ชฉ๋ก์—์„œ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค. +- Plan ์ƒ์„ธ์—์„œ ์ž๋™ํ™”๋กœ ์ ‘์ˆ˜๋œ ์›๋ณธ ์š”์ฒญ์€ ์ˆ˜์ • ๋Œ€์‹  ์กฐ์น˜ ๊ธฐ๋ก๊ณผ ์ฆ์  ํ™•์ธ ์ค‘์‹ฌ์œผ๋กœ ๋‹ค๋ฃน๋‹ˆ๋‹ค. +- Plans ํ™”๋ฉด์€ ์ค‘๊ฐ„ ์•ˆ๋‚ด ์นด๋“œ๋ณด๋‹ค ์‹ค์ œ ์ž‘์—… ํ™”๋ฉด์„ ๋ฐ”๋กœ ์—ฌ๋Š” ๊ตฌ์„ฑ์ด ์šฐ์„ ์ž…๋‹ˆ๋‹ค. +- ์˜ค๋Š˜ ๋Œ€ํ‘œ ์ฆ์  ํ™”๋ฉด์€ `Plan/์ž๋™ํ™”` ์ „์ฒด ํ™”๋ฉด, ๋ถ€๋ถ„ ์ฆ์ ์€ ์ƒ๋‹จ ๊ฐœ์š” ์นด๋“œ๋กœ ํ†ต์ผํ•ฉ๋‹ˆ๋‹ค. + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- ์˜ค์ „ ์ดˆ๋ฐ˜์—๋Š” ๊ฒŒ์‹œํŒ setup ๊ฒฝํ•ฉ๊ณผ ์ž๋™ํ™” ์ ‘์ˆ˜ ์ง„์ž…์ ์„ ๋จผ์ € ์ •๋ฆฌํ•ด, ๊ฒŒ์‹œํŒ ๊ธ€์ด ์‹คํŒจ ์—†์ด ์—ด๋ฆฌ๊ณ  ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋ฐ”๋กœ ์ž๋™ํ™” ์š”์ฒญ์œผ๋กœ ์ด์–ด์ง€๊ฒŒ ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. +- ์ด์–ด์„œ ๊ฒŒ์‹œํŒ ๊ธ€์„ Markdown ์ƒ์„ฑ ์ž‘์—…์œผ๋กœ ๋Œ๋ฆฌ๋Š” ์ž๋™ํ™” ํ๋ฆ„์„ ๋ถ™์˜€๊ณ , ์ถ”์ฒœ ๋ฌธ์„œ ์ƒ์„ฑ ๊ทœ์น™๊ณผ ์Šค์ผ€์ค„ ์กฐ๊ฑด์„ ๊ณ„์† ์ˆ˜์ •ํ•˜๋ฉด์„œ ๋นˆ ํ์—์„œ๋„ ๋‹ค์Œ ์ฃผ๊ธฐ๋ฅผ ์œ ์ง€ํ•˜๋„๋ก ๋‹ค๋“ฌ์—ˆ์Šต๋‹ˆ๋‹ค. +- ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์ถ”๊ฐ€๋Š” ์ดํ›„ ํ™”๋ฉด ์ •๋ฆฌ์— ํ•„์š”ํ•œ ๊ธฐ๋ฐ˜ ์ž‘์—…์œผ๋กœ ์ง„ํ–‰ํ–ˆ๊ณ , ์ƒˆ ์ž…๋ ฅ/์ƒํƒœ/๋ชฉ๋ก ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ƒ˜ํ”Œ๊ณผ ํ•จ๊ป˜ ๊ณต๊ฐœํ•ด ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ† ๋Œ€๋ฅผ ๋งˆ๋ จํ–ˆ์Šต๋‹ˆ๋‹ค. +- ์ค‘๋ฐ˜ ์ž‘์—…์—์„œ๋Š” ๋ฐ˜๋ณต ์š”์ฒญ ์˜ต์…˜์„ ๊ฑท์–ด๋‚ด๊ณ  ์ „์šฉ ์Šค์ผ€์ค„ ๊ธฐ๋Šฅ์œผ๋กœ ์น˜ํ™˜ํ•˜๋Š” ์ชฝ์œผ๋กœ ๋ฐฉํ–ฅ์„ ๋ฐ”๊ฟจ์Šต๋‹ˆ๋‹ค. ์ด ๊ณผ์ •์—์„œ ์ €์žฅ ๊ตฌ์กฐ, API, ์›Œ์ปค, ํ™”๋ฉด์„ ํ•จ๊ป˜ ์›€์ง์—ฌ ๋ฐ˜๋ณต ์ž‘์—… ๊ด€๋ฆฌ์˜ ์ค‘์‹ฌ์„ ์Šค์ผ€์ค„ ํ™”๋ฉด์œผ๋กœ ์˜ฎ๊ฒผ์Šต๋‹ˆ๋‹ค. +- ์ดํ›„ Plans ๋„ค๋น„๊ฒŒ์ด์…˜์€ ์‹ค์ œ ์‚ฌ์šฉ ํ๋ฆ„์— ๋งž๊ฒŒ ๋‹ค์‹œ ์ ‘์—ˆ๊ณ , ๊ฒŒ์‹œํŒ ๋ณต๊ตฌ, ์ฐจํŠธ/์Šค์ผ€์ค„/ํžˆ์Šคํ† ๋ฆฌ ์žฌ์—ฐ๊ฒฐ, ์šฉ์–ด ์ •๋ฆฌ, ๊ฒ€์ƒ‰ ๋ผ๋ฒจ ํ†ต์ผ์„ ๊ฑฐ์ณ ์ง€๊ธˆ ๊ตฌ์กฐ๋กœ ์ˆ˜๋ ด์‹œ์ผฐ์Šต๋‹ˆ๋‹ค. +- ์˜คํ›„ ํ›„๋ฐ˜์—๋Š” Plan ๋ชฉ๋ก๊ณผ ์ƒ์„ธ ํ™”๋ฉด ํ’ˆ์งˆ์„ ์ง‘์ค‘ ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. ์กฐํ•ฉ ํ•„ํ„ฐ, ์ฒดํฌ๋ฆฌ์ŠคํŠธ, ๋ฆด๋ฆฌ์ฆˆ ์š”์•ฝ, ์ฆ์  ํƒญ, ๊ฒŒ์‹œํŒ ์—ฐ๊ฒฐ, ์ด์Šˆ ์กฐ์น˜ ๊ธฐ๋ก์ด ์ˆœ์ฐจ์ ์œผ๋กœ ๋ถ™์—ˆ๊ณ , ๋ณธ๋ฌธ ์ตœ๋Œ€ํ™”์™€ ์ „์ฒดํ™”๋ฉด ์Šคํฌ๋กค ์ œ์–ด๋„ ์—ฌ๊ธฐ์„œ ํ•จ๊ป˜ ์ •๋ฆฌ๋์Šต๋‹ˆ๋‹ค. +- ๋งˆ๊ฐ ๋‹จ๊ณ„์—์„œ๋Š” ์ž๋™ํ™” ์ ‘์ˆ˜ ๊ฑด์˜ ์ž ๊ธˆ ๊ทœ์น™์„ ๋‹ค์‹œ ์ ๊ฒ€ํ•ด ์šฐํšŒ ์ˆ˜์ • ๊ฐ€๋Šฅ์„ฑ์„ ์ค„์˜€๊ณ , ๋น„ํ† ํฐ ์‚ฌ์šฉ์ž ์ฝ๊ธฐ ์ „์šฉ ์ œ์–ด์™€ ์•Œ๋ฆผ ๋งํฌ ์—ฐ๊ฒฐ๊นŒ์ง€ ๋ณด์™„ํ•œ ๋’ค ์˜ค๋Š˜ ์ž‘์—…์ผ์ง€์™€ ์ฆ์  ์บก์ฒ˜๋ฅผ ์ตœ์‹  ์ƒํƒœ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ์Šคํฌ๋ฆฐ์ƒท + +![feature-plans-automation-full](../assets/worklogs/2026-04-11/feature-plans-automation-full.png) + +- `Plan/์ž๋™ํ™”` ์ „์ฒด ํ™”๋ฉด ์บก์ฒ˜์ž…๋‹ˆ๋‹ค. ์˜ค๋Š˜ ์ •๋ฆฌํ•œ ๋ชฉ๋ก, ์ƒ์„ธ ์ง„์ž… ํ๋ฆ„, ์ƒ๋‹จ ๊ฐœ์š” ๊ตฌ์„ฑ์ด ํ•œ ํ™”๋ฉด์—์„œ ๋ณด์ด๋„๋ก ์ „์ฒด ํŽ˜์ด์ง€ ๊ธฐ์ค€์œผ๋กœ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. + +![plan-board-overview](../assets/worklogs/2026-04-11/plan-board-overview.png) + +- ์ƒ๋‹จ ๊ฐœ์š” ์นด๋“œ ๋ถ€๋ถ„ ์บก์ฒ˜์ž…๋‹ˆ๋‹ค. ์ž๋™ ์ƒˆ๋กœ๊ณ ์นจ ์ƒํƒœ, ์ž‘์—… ๊ฐœ์ˆ˜ ์š”์•ฝ, ์•ˆ๋‚ด ๋ฌธ๊ตฌ์ฒ˜๋Ÿผ ์˜ค๋Š˜ ๋ณด๊ฐ•ํ•œ ์šด์˜ ๋ฌธ๋งฅ์„ ๋น ๋ฅด๊ฒŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋„๋ก ๋”ฐ๋กœ ๋‚จ๊ฒผ์Šต๋‹ˆ๋‹ค. + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: `etc/servers/work-server/src/routes/board.ts`, `etc/servers/work-server/src/services/board-service.ts`, `src/features/board/BoardPage.tsx`, `src/features/board/api.ts`, `src/features/board/types.ts` + +- ๊ฒŒ์‹œํŒ ๊ธ€์„ ์ฆ‰์‹œ ์ž๋™ํ™” Plan์œผ๋กœ ์ ‘์ˆ˜ํ•˜๊ณ , ์ ‘์ˆ˜ ์ค‘๋ณต๊ณผ ๊ถŒํ•œ์„ ํ•จ๊ป˜ ์ฒ˜๋ฆฌํ•˜๋Š” ํ๋ฆ„์ž…๋‹ˆ๋‹ค. + +```diff ++app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => { ++ if (!requireBoardAutomationAccess(request, reply)) { ++ return; ++ } ++ const result = await receiveBoardPostAutomation(id); ++ return { ok: true, item: result.item, planItemId: result.planItemId, alreadyReceived: result.alreadyReceived }; ++}); ++if (currentRow.automation_received_at || currentRow.automation_plan_item_id) { ++ return { item: mapBoardPostRow(currentRow), planItemId: ..., alreadyReceived: true }; ++} ++const workId = `board-post-${id}`; ++note: [`๊ฒŒ์‹œํŒ ์ œ๋ชฉ: ${title}`, '', content].join('\n'), ++auto_deploy_to_main: false, ++const [automationReceivingId, setAutomationReceivingId] = useState(null); ++const [automationReceiveError, setAutomationReceiveError] = useState(null); ++icon={} ++Tag color={automationStatus.color} +``` + +### ํŒŒ์ผ 2: `etc/servers/work-server/src/services/board-service.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `etc/servers/work-server/src/services/app-config-service.ts`, `src/app/main/MainHeader.tsx`, `src/app/main/appConfig.ts` + +- ๊ฒŒ์‹œํŒ Markdown ์ž๋™ ๋“ฑ๋ก ์ž๋™ํ™”๋ฅผ ์„ค์ •๊ณผ ์›Œ์ปค ์ฃผ๊ธฐ ๊ณ„์‚ฐ๊นŒ์ง€ ํฌํ•จํ•ด ์šด์˜ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ๋กœ ๋ฌถ์—ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++markdownPlanItemId: number | null; ++markdownExportedAt: string | null; ++function buildBoardPostMarkdownPlanNote(row: Record, targetFolder: string) { ++ return [ ++ `๊ฒŒ์‹œํŒ ์ถ”์ฒœ ๊ธ€ #${id}์„ Markdown ๋ฌธ์„œ๋กœ ๋“ฑ๋กํ•ด ์ฃผ์„ธ์š”.`, ++ `๋Œ€์ƒ ํŒŒ์ผ: ${targetPath}`, ++ ].join('\n'); ++} ++export async function createNextBoardPostMarkdownAutomationPlan(targetFolder: string, releaseTarget = 'release') { ++ work_id: `board-md-${id}`, ++ auto_deploy_to_main: true, ++} ++private evaluateBoardMarkdownAutomationSchedule(config, now) { ++ const scheduleType = config?.scheduleType ?? 'interval'; ++ return { due, nextEligibleAt, scheduleLabel }; ++} ++await this.processBoardMarkdownAutomation(appConfig); ++intervalMinutes: z.coerce.number().int().min(1).default(5) +``` + +### ํŒŒ์ผ 3: `src/components/formField/FormField.tsx`, `src/components/stateKit/StateKit.tsx`, `src/components/dataListTable/DataListTable.tsx`, `src/index.ts` + +- ์ž…๋ ฅ/์ƒํƒœ/๋ชฉ๋ก UI๋ฅผ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌํ•ด ์ดํ›„ Plan, Board, History ํ™”๋ฉด์ด ๊ฐ™์€ ํ† ๋Œ€ ์œ„์—์„œ ํ™•์žฅ๋˜๋„๋ก ๋งŒ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++export type DataListTableProps = { ++ data: T[]; ++ searchFields?: ReadonlyArray string)>; ++ filters?: ReadonlyArray>; ++ mobileCardRender?: (item: T) => ReactNode; ++}; ++export function DataListTable({ ... }: DataListTableProps) { ++ const filteredData = useMemo(() => { ... }, [...]); ++ return ... />; ++} ++export type FormFieldProps = Omit & { ++ error?: ReactNode; ++ children: ReactNode | ((state: FormFieldRenderState) => ReactNode); ++}; ++export { FormField } from './components/formField'; ++export { StateKit } from './components/stateKit'; ++export { DataListTable } from './components/dataListTable'; +``` + +### ํŒŒ์ผ 4: `etc/servers/work-server/src/services/plan-service.ts`, `etc/servers/work-server/src/services/plan-schedule-service.ts`, `etc/servers/work-server/src/routes/plan.ts`, `etc/servers/work-server/src/workers/plan-worker.ts`, `src/features/planBoard/PlanSchedulePage.tsx`, `src/features/planBoard/api.ts` + +- ๋ฐ˜๋ณต ์š”์ฒญ์„ ๋ฉ”๋ชจ ์˜ต์…˜์—์„œ ๋ถ„๋ฆฌํ•˜๊ณ  ์ „์šฉ ์Šค์ผ€์ค„ ์ €์žฅ์†Œ์™€ ํ™”๋ฉด์œผ๋กœ ์žฌํŽธํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; ++export const createPlanScheduledTaskSchema = z.object({ ++ workId: z.string().trim().optional().default('๋ฐ˜๋ณต์ž‘์—…'), ++ repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60), ++}); ++app.get('/api/plan/scheduled-tasks', async (request, reply) => { ... }); ++app.post('/api/plan/scheduled-tasks', async (request, reply) => { ... }); ++app.patch('/api/plan/scheduled-tasks/:id', async (request, reply) => { ... }); ++app.delete('/api/plan/scheduled-tasks/:id', async (request, reply) => { ... }); ++await ensurePlanScheduledTaskTable(); ++return fetchWithFallback('/api/plan/scheduled-tasks', '/api/plans/scheduled-tasks'); ++'/plan/schedule', '/plan/schedules', '/plans/schedule', '/plans/schedules' +``` + +### ํŒŒ์ผ 5: `src/features/planBoard/PlanBoardPage.tsx`, `src/features/history/HistoryPage.tsx`, `src/features/board/BoardPage.tsx`, `src/styles.css` + +- Plan ๋ชฉ๋ก๊ณผ ์ƒ์„ธ๋Š” ํ•„ํ„ฐ, ์š”์•ฝ, ์ฆ์ , ๋ณธ๋ฌธ ์ตœ๋Œ€ํ™”, ์ž ๊ธˆ ๊ทœ์น™, ์ฝ๊ธฐ ์ „์šฉ ์ œ์–ด๋ฅผ ์ค‘์‹ฌ์œผ๋กœ ๊ณ„์† ๋ณด๊ฐ•ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++function isPlanItemRequestLocked(item: Pick | null | undefined) { ++ return Boolean(item?.startedAt); ++} ++const [noteExpanded, setNoteExpanded] = useState(false); ++const canAppendActionHistory = hasAccess && Boolean(selectedItem) && isPlanItemRequestLocked(selectedItem); ++if (!hasAccess) { ++ messageApi.error('์„ค์ • > ํ† ํฐ ๊ด€๋ฆฌ์—์„œ ๊ถŒํ•œ ํ† ํฐ์„ ๋“ฑ๋กํ•œ ์‚ฌ์šฉ์ž๋งŒ ์กฐ์น˜ ์ด๋ ฅ์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'); ++} ++if (isPlanItemRequestLocked(currentItem)) { ++ messageApi.warning('์ž๋™ํ™” ์ ‘์ˆ˜๋œ ํ•ญ๋ชฉ์€ ๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); ++} ++className={`plan-board-page__notepad-frame${noteExpanded ? ' plan-board-page__notepad-frame--expanded' : ''}`} ++className={`board-page__editor-frame${contentExpanded ? ' board-page__editor-frame--expanded' : ''}`} ++options={FUNCTION_CHECK_OPTIONS} ++disabled={!hasAccess || jangsingProcessingSavingId === item.id || isPlanItemRequestLocked(item)} +``` + +### ํŒŒ์ผ 6: `src/app/main/MainContent.tsx`, `src/app/main/layout/MainLayout.tsx`, `src/app/main/layout/buildSearchOptions.ts`, `src/app/main/mainView/constants.tsx`, `src/app/main/mainView/navigation.ts`, `src/app/main/mainView/searchOptions.ts`, `src/app/main/pages/PlansPage.tsx`, `src/app/main/routes.tsx`, `src/app/main/MainHeader.tsx` + +- Plans ์ง„์ž… ๊ตฌ์กฐ๋ฅผ ๋‹จ์ˆœํ™”ํ•˜๊ณ , ๊ฒŒ์‹œํŒ/์ฐจํŠธ/์Šค์ผ€์ค„/ํžˆ์Šคํ† ๋ฆฌ ๋ณต๊ตฌ์™€ ์šฉ์–ด ์ •๋ฆฌ๋ฅผ ๊ฐ™์€ ์ถ•์—์„œ ๋งˆ๋ฌด๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++type PlanSectionKey = 'all' | 'board' | 'charts' | 'schedule' | 'history' | 'release'; ++navigateTo(buildPlansPath('board')); ++navigateTo(buildPlansPath('schedule')); ++navigateTo(buildPlansPath('history')); ++label: '์ž๋™ํ™”' ++label: 'Plan' ++title: '์ž๋™ํ™”' ++return ; ++release target link -> '*.sm-home.cloud' ++search label: 'Plan' +``` + +### ํŒŒ์ผ 7: `etc/servers/work-server/src/services/plan-notification-service.ts`, `src/sw.js`, `src/components/previewer/PreviewerUI.tsx`, `src/components/previewer/CodexDiffPreviewer.tsx` + +- ์•Œ๋ฆผ ํด๋ฆญ ์ด๋™๊ณผ ์ „์ฒดํ™”๋ฉด ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋Š” ๋งˆ์ง€๋ง‰ ํ’ˆ์งˆ ๋ณด์™„์œผ๋กœ ์ •๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++function buildPlanNotificationTargetUrl(planId: number, workId: string | null | undefined, eventType: string) { ++ const baseUrl = eventType.startsWith('release-') ? 'https://rel.sm-home.cloud/' : 'https://sm-home.cloud/'; ++ targetUrl.searchParams.set('planId', String(planId)); ++} ++targetUrl = notificationData.targetUrl ? new URL(String(notificationData.targetUrl)) : new URL('/', self.location.origin); ++const scrollY = window.scrollY; ++document.body.style.position = 'fixed'; ++document.body.style.top = `-${scrollY}px`; ++document.documentElement.style.overflow = 'hidden'; ++window.scrollTo(0, scrollY); +``` + +### ํŒŒ์ผ 8: `docs/components/component-addition-suggestions.md` + +- ๊ฒŒ์‹œํŒ ๋…ผ์˜ ๊ฒฐ๊ณผ๋ฅผ ๋ฌธ์„œ ์ฆ์ ์œผ๋กœ ์˜ฎ๊ฒจ ์˜ค๋Š˜ ์ œ์•ˆ ์ •๋ฆฌ ํ๋ฆ„๋„ ๋ฌธ์„œ ์„ธํŠธ ์•ˆ์—์„œ ๋ฐ”๋กœ ํ™•์ธํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค. + +```diff ++## ์ด๋ฒˆ ๋ฐ˜์˜ ์š”์•ฝ ++- FormField: ํผ ๋ ˆ์ด๋ธ”, ๋„์›€๋ง, ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๊ฐ™์€ ํฌ๋งท์œผ๋กœ ๋ฌถ๋Š” ๊ณตํ†ต ์ž…๋ ฅ ๋ ˆ์ด์–ด ++- StateKit: ๋กœ๋”ฉ, ๋น„์–ด ์žˆ์Œ, ์˜ค๋ฅ˜ ์ƒํƒœ๋ฅผ ํ™”๋ฉด๋งˆ๋‹ค ๋ฐ˜๋ณต ์ž‘์„ฑํ•˜์ง€ ์•Š๋„๋ก ์ •๋ฆฌํ•œ ์ƒํƒœ ํ‘œํ˜„ ๋ฌถ์Œ ++- DataListTable: ๊ฒ€์ƒ‰, ํ•„ํ„ฐ, ๋ชจ๋ฐ”์ผ ์นด๋“œ ๋Œ€์ฒด ๋ Œ๋”๋ฅผ ํ•จ๊ป˜ ์ œ๊ณตํ•˜๋Š” ๊ณตํ†ต ๋ชฉ๋ก ๋ ˆ์ด์–ด +``` + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +```bash +find docs/worklogs -maxdepth 2 -type f -name '*.md' | sort +grep -RInE '์ž‘์—…์ผ์ง€|์‹คํ–‰ ์ปค๋งจ๋“œ|์†Œ์Šค ํƒญ|์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ' docs/worklogs --include='*.md' +git -c safe.directory=/workspace/auto_codex/repo branch --show-current +git -c safe.directory=/workspace/auto_codex/repo status --short --branch +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-11 00:00' --pretty=format:'%h %ad %d %s' --date=iso-local --reverse --all +git -c safe.directory=/workspace/auto_codex/repo log --since='2026-04-11 00:00' --name-status --pretty=format:'commit %h' --all +git -c safe.directory=/workspace/auto_codex/repo diff 6f7a9aa..a6a3c92 -- src/features/planBoard/PlanBoardPage.tsx +git -c safe.directory=/workspace/auto_codex/repo show 818cc2f -- src/features/board/BoardPage.tsx src/features/board/api.ts src/features/board/types.ts etc/servers/work-server/src/routes/board.ts etc/servers/work-server/src/services/board-service.ts +git -c safe.directory=/workspace/auto_codex/repo show d9d124b -- src/features/planBoard/PlanSchedulePage.tsx src/features/planBoard/api.ts etc/servers/work-server/src/routes/plan.ts etc/servers/work-server/src/services/plan-schedule-service.ts etc/servers/work-server/src/workers/plan-worker.ts +git -c safe.directory=/workspace/auto_codex/repo show dd2e975 -- src/components/formField/FormField.tsx src/components/stateKit/StateKit.tsx src/components/dataListTable/DataListTable.tsx src/index.ts +git -c safe.directory=/workspace/auto_codex/repo show 4e48813 -- src/app/main/MainHeader.tsx src/app/main/appConfig.ts etc/servers/work-server/src/workers/plan-worker.ts etc/servers/work-server/src/services/board-service.ts +npm run build:app +PORT=4173 node scripts/serve-app-dist.mjs +curl -I http://127.0.0.1:4173/plans/all +node --input-type=module <<'EOF' +// Playwright๋กœ /plans/all ์ง„์ž… ํ›„ ์ „์ฒด ํ™”๋ฉด๊ณผ ๊ฐœ์š” ์นด๋“œ ์บก์ฒ˜ ์ €์žฅ +EOF +find docs/assets/worklogs/2026-04-11 -maxdepth 1 -type f | sort +``` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ + +- A docs/worklogs/2026-04-11.md +- A docs/assets/worklogs/2026-04-11/feature-plans-automation-full.png +- A docs/assets/worklogs/2026-04-11/plan-board-overview.png +- M docs/components/component-addition-suggestions.md +- M etc/db/work-db/sql/board-posts.sql +- M etc/servers/work-server/src/routes/board.ts +- M etc/servers/work-server/src/routes/plan.ts +- M etc/servers/work-server/src/routes/visitor-history.ts +- M etc/servers/work-server/src/services/app-config-service.ts +- M etc/servers/work-server/src/services/board-service.ts +- M etc/servers/work-server/src/services/plan-notification-service.ts +- A etc/servers/work-server/src/services/plan-schedule-service.ts +- M etc/servers/work-server/src/services/plan-service.ts +- M etc/servers/work-server/src/services/visitor-history-service.ts +- M etc/servers/work-server/src/services/worklog-automation-service.ts +- M etc/servers/work-server/src/services/worklog-automation-utils.ts +- A etc/servers/work-server/src/workers/plan-worker.test.ts +- M etc/servers/work-server/src/workers/plan-worker.ts +- M src/app/main/MainContent.tsx +- M src/app/main/MainHeader.tsx +- M src/app/main/appConfig.ts +- M src/app/main/clientIdentity.ts +- M src/app/main/layout/MainLayout.tsx +- M src/app/main/layout/buildSearchOptions.ts +- M src/app/main/mainView/constants.tsx +- M src/app/main/mainView/navigation.ts +- M src/app/main/mainView/searchOptions.ts +- M src/app/main/pages/PlansPage.tsx +- M src/app/main/routes.tsx +- A src/components/dataListTable/DataListTable.css +- A src/components/dataListTable/DataListTable.tsx +- A src/components/dataListTable/index.ts +- A src/components/dataListTable/samples/BaseSample.tsx +- A src/components/formField/FormField.css +- A src/components/formField/FormField.tsx +- A src/components/formField/index.ts +- A src/components/formField/samples/BaseSample.tsx +- M src/components/previewer/CodexDiffPreviewer.tsx +- M src/components/previewer/PreviewerUI.tsx +- A src/components/stateKit/StateKit.css +- A src/components/stateKit/StateKit.tsx +- A src/components/stateKit/index.ts +- A src/components/stateKit/samples/BaseSample.tsx +- M src/features/board/BoardPage.tsx +- M src/features/board/api.ts +- M src/features/board/types.ts +- M src/features/history/HistoryPage.tsx +- M src/features/history/api.ts +- M src/features/planBoard/PlanBoardPage.tsx +- A src/features/planBoard/PlanSchedulePage.tsx +- M src/features/planBoard/api.ts +- M src/features/planBoard/index.ts +- M src/features/planBoard/types.ts +- M src/index.ts +- M src/store/appStore/context/AppStoreContext.tsx +- M src/styles.css +- M src/sw.js diff --git a/etc/commands/server-command/restart-rel.sh b/etc/commands/server-command/restart-rel.sh new file mode 100755 index 0000000..0a8968f --- /dev/null +++ b/etc/commands/server-command/restart-rel.sh @@ -0,0 +1,27 @@ +#!/bin/sh + +set -eu + +MAIN_PROJECT_ROOT="${MAIN_PROJECT_ROOT:-/workspace/main-project}" +SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/docker-compose.yml}" +SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-release-app}" +SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release}" +SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}" +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +cd "$MAIN_PROJECT_ROOT" + +if command -v docker >/dev/null 2>&1; then + if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then + exit 0 + fi + + exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE" +fi + +if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then + exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME" +fi + +echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2 +exit 127 diff --git a/etc/commands/server-command/restart-server-command-runner.sh b/etc/commands/server-command/restart-server-command-runner.sh new file mode 100755 index 0000000..e9a3526 --- /dev/null +++ b/etc/commands/server-command/restart-server-command-runner.sh @@ -0,0 +1,53 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PROJECT_ROOT="${PROJECT_ROOT:-$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)}" +RUNNER_SCRIPT="${SERVER_COMMAND_RUNNER_SCRIPT:-$PROJECT_ROOT/scripts/run-server-command-runner.mjs}" +RUNNER_NODE_BIN="${SERVER_COMMAND_RUNNER_NODE_BIN:-node}" +RUNNER_NVM_DIR="${SERVER_COMMAND_RUNNER_NVM_DIR:-$HOME/.nvm}" +RUNNER_HOST="${SERVER_COMMAND_RUNNER_HOST:-0.0.0.0}" +RUNNER_PORT="${SERVER_COMMAND_RUNNER_PORT:-3211}" +RUNNER_ACCESS_TOKEN="${SERVER_COMMAND_RUNNER_ACCESS_TOKEN:-local-server-command-runner}" +RUNNER_LOG_FILE="${SERVER_COMMAND_RUNNER_LOG_FILE:-/tmp/server-command-runner.log}" +RUNNER_HEARTBEAT_FILE="${SERVER_COMMAND_RUNNER_HEARTBEAT_FILE:-$PROJECT_ROOT/.server-command-runner-heartbeat.json}" +RUNNER_CPU_WATCHDOG_ENABLED="${SERVER_COMMAND_CPU_WATCHDOG_ENABLED:-true}" +RUNNER_CPU_WATCHDOG_INTERVAL_MS="${SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS:-60000}" +RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT="${SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT:-120}" +RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT="${SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT:-8}" +RUNNER_CPU_WATCHDOG_COOLDOWN_MS="${SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS:-1200000}" +RUNNER_SCRIPT_BASENAME=$(basename "$RUNNER_SCRIPT") + +if [ "$RUNNER_NODE_BIN" = "node" ] && [ -s "$RUNNER_NVM_DIR/nvm.sh" ]; then + # Fresh-PC shells often miss the nvm-managed Node.js path in non-login execution. + . "$RUNNER_NVM_DIR/nvm.sh" + if command -v node >/dev/null 2>&1; then + RUNNER_NODE_BIN=$(command -v node) + fi +fi + +if ! command -v "$RUNNER_NODE_BIN" >/dev/null 2>&1; then + echo "node runtime not found: $RUNNER_NODE_BIN" >&2 + exit 1 +fi + +RUNNER_PIDS=$(ps -ef | grep "$RUNNER_SCRIPT_BASENAME" | grep -v grep | awk '{print $2}' || true) +if [ -n "$RUNNER_PIDS" ]; then + kill $RUNNER_PIDS || true + sleep 1 +fi + +setsid env \ + SERVER_COMMAND_RUNNER_HOST="$RUNNER_HOST" \ + SERVER_COMMAND_RUNNER_PORT="$RUNNER_PORT" \ + SERVER_COMMAND_RUNNER_ACCESS_TOKEN="$RUNNER_ACCESS_TOKEN" \ + SERVER_COMMAND_RUNNER_HEARTBEAT_FILE="$RUNNER_HEARTBEAT_FILE" \ + SERVER_COMMAND_CPU_WATCHDOG_ENABLED="$RUNNER_CPU_WATCHDOG_ENABLED" \ + SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS="$RUNNER_CPU_WATCHDOG_INTERVAL_MS" \ + SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT="$RUNNER_CPU_WATCHDOG_THRESHOLD_PERCENT" \ + SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT="$RUNNER_CPU_WATCHDOG_CONSECUTIVE_LIMIT" \ + SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS="$RUNNER_CPU_WATCHDOG_COOLDOWN_MS" \ + "$RUNNER_NODE_BIN" "$RUNNER_SCRIPT" >>"$RUNNER_LOG_FILE" 2>&1 /dev/null 2>&1; then + if docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" restart "$SERVER_COMMAND_SERVICE"; then + exit 0 + fi + + exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "$SERVER_COMMAND_SERVICE" +fi + +if [ -S "$SERVER_COMMAND_DOCKER_SOCKET" ]; then + exec node "$SCRIPT_DIR/restart-via-docker-socket.mjs" "$SERVER_COMMAND_CONTAINER_NAME" +fi + +echo "docker CLI not found and Docker socket is unavailable: $SERVER_COMMAND_DOCKER_SOCKET" >&2 +exit 127 diff --git a/etc/commands/server-command/restart-via-docker-socket.mjs b/etc/commands/server-command/restart-via-docker-socket.mjs new file mode 100755 index 0000000..bf68760 --- /dev/null +++ b/etc/commands/server-command/restart-via-docker-socket.mjs @@ -0,0 +1,63 @@ +import fs from 'node:fs'; +import http from 'node:http'; + +function requestDocker(socketPath, requestPath, method) { + return new Promise((resolve, reject) => { + const request = http.request( + { + socketPath, + path: requestPath, + method, + }, + (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + resolve({ + statusCode: response.statusCode ?? 500, + body, + }); + }); + }, + ); + + request.on('error', reject); + request.end(); + }); +} + +async function main() { + const containerName = process.argv[2]?.trim(); + const socketPath = process.env.SERVER_COMMAND_DOCKER_SOCKET?.trim() || '/var/run/docker.sock'; + + if (!containerName) { + console.error('container name is required'); + process.exit(1); + } + + if (!fs.existsSync(socketPath)) { + console.error(`Docker socket not found: ${socketPath}`); + process.exit(127); + } + + const restartPath = `/containers/${encodeURIComponent(containerName)}/restart?t=30`; + const response = await requestDocker(socketPath, restartPath, 'POST'); + + if (response.statusCode >= 200 && response.statusCode < 300) { + process.stdout.write(`${containerName} restarted via Docker socket`); + return; + } + + if (response.statusCode === 404) { + console.error(`Container not found: ${containerName}`); + process.exit(1); + } + + console.error(`Docker socket restart failed (${response.statusCode}): ${response.body.trim()}`); + process.exit(1); +} + +await main(); diff --git a/etc/commands/server-command/restart-work-server.sh b/etc/commands/server-command/restart-work-server.sh new file mode 100755 index 0000000..b48ce65 --- /dev/null +++ b/etc/commands/server-command/restart-work-server.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) + +cd "$REPO_ROOT" +exec docker compose -f etc/servers/work-server/docker-compose.yml up -d --build --force-recreate --no-deps work-server diff --git a/etc/db/work-db/.env.example b/etc/db/work-db/.env.example new file mode 100755 index 0000000..c83ea17 --- /dev/null +++ b/etc/db/work-db/.env.example @@ -0,0 +1,4 @@ +POSTGRES_DB=work_db +POSTGRES_USER=work_user +POSTGRES_PASSWORD=change-me +POSTGRES_PORT=5432 diff --git a/etc/db/work-db/.gitignore b/etc/db/work-db/.gitignore new file mode 100755 index 0000000..82d8eec --- /dev/null +++ b/etc/db/work-db/.gitignore @@ -0,0 +1,2 @@ +.env +postgres-data diff --git a/etc/db/work-db/README.md b/etc/db/work-db/README.md new file mode 100755 index 0000000..1854532 --- /dev/null +++ b/etc/db/work-db/README.md @@ -0,0 +1,28 @@ +# Work DB + +๋กœ์ปฌ ๊ฐœ๋ฐœ์šฉ PostgreSQL ์ปจํ…Œ์ด๋„ˆ์ž…๋‹ˆ๋‹ค. + +## ์‹คํ–‰ + +```bash +docker compose up -d +docker compose logs -f postgres +``` + +## ์ค‘์ง€ + +```bash +docker compose down +``` + +## ๊ธฐ๋ณธ ์ ‘์† ์ •๋ณด + +- Host: `localhost` +- Port: `.env`์˜ `POSTGRES_PORT` +- Database: `.env`์˜ `POSTGRES_DB` +- User: `.env`์˜ `POSTGRES_USER` +- Password: `.env`์˜ `POSTGRES_PASSWORD` + +## work-server ์—ฐ๋™ + +`etc/servers/work-server/.env`์˜ DB ์„ค์ •๊ณผ ๋งž์ถฐ์„œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. diff --git a/etc/db/work-db/docker-compose.yml b/etc/db/work-db/docker-compose.yml new file mode 100755 index 0000000..e964c71 --- /dev/null +++ b/etc/db/work-db/docker-compose.yml @@ -0,0 +1,41 @@ +services: + postgres: + image: postgres:16-alpine + container_name: work-db + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + restart: unless-stopped + env_file: + - path: ./.env.example + required: false + - path: ./.env + required: false + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - '${POSTGRES_PORT:-5432}:5432' + volumes: + - work-db-data:/var/lib/postgresql/data + networks: + - work-backend + healthcheck: + test: + [ + 'CMD-SHELL', + 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB' + ] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + work-db-data: + +networks: + work-backend: + name: work-backend diff --git a/etc/db/work-db/sql/board-posts.sql b/etc/db/work-db/sql/board-posts.sql new file mode 100755 index 0000000..93c9985 --- /dev/null +++ b/etc/db/work-db/sql/board-posts.sql @@ -0,0 +1,12 @@ +create table if not exists board_posts ( + id serial primary key, + title varchar(200) not null, + content text not null, + automation_plan_item_id integer null, + automation_received_at timestamptz null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_board_posts_updated_at + on board_posts (updated_at desc); diff --git a/etc/db/work-db/sql/notification-messages.sql b/etc/db/work-db/sql/notification-messages.sql new file mode 100755 index 0000000..cc5a12c --- /dev/null +++ b/etc/db/work-db/sql/notification-messages.sql @@ -0,0 +1,16 @@ +create table if not exists notification_messages ( + id serial primary key, + title varchar(200) not null, + body text not null, + category varchar(60) not null default 'general', + source varchar(80) not null default 'system', + priority varchar(20) not null default 'normal', + is_read boolean not null default false, + read_at timestamptz null, + metadata_json jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_notification_messages_unread_created_at + on notification_messages (is_read, created_at desc, id desc); diff --git a/etc/db/work-db/sql/visitor-history.sql b/etc/db/work-db/sql/visitor-history.sql new file mode 100755 index 0000000..f28e469 --- /dev/null +++ b/etc/db/work-db/sql/visitor-history.sql @@ -0,0 +1,34 @@ +-- ๋ฐฉ๋ฌธ์ž ๋งˆ์Šคํ„ฐ ํ…Œ์ด๋ธ”: clientId ๋‹จ์œ„ ์ง‘๊ณ„ ์ •๋ณด ๋ณด๊ด€ +create table if not exists visitor_clients ( + id serial primary key, + client_id varchar(120) not null unique, + nickname varchar(80) not null, + first_visited_at timestamptz not null default now(), + last_visited_at timestamptz not null default now(), + visit_count integer not null default 1, + last_visited_url varchar(2000), + last_user_agent varchar(1000), + last_ip varchar(120), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_visitor_clients_last_visited_at + on visitor_clients (last_visited_at desc); + +-- ๋ฐฉ๋ฌธ ์ƒ์„ธ ์ด๋ ฅ ํ…Œ์ด๋ธ”: ์ค‘๋ณต ๋ฐฉ๋ฌธ๋„ ๋ชจ๋‘ ์ ์žฌ +create table if not exists visitor_visit_histories ( + id serial primary key, + client_id varchar(120) not null, + visited_at timestamptz not null default now(), + url varchar(2000) not null, + event_type varchar(80) not null default 'page_view', + user_agent varchar(1000), + ip varchar(120) +); + +create index if not exists idx_visitor_visit_histories_client_id + on visitor_visit_histories (client_id); + +create index if not exists idx_visitor_visit_histories_visited_at + on visitor_visit_histories (visited_at desc); diff --git a/etc/servers/work-server/.env.example b/etc/servers/work-server/.env.example new file mode 100644 index 0000000..0c4cf48 --- /dev/null +++ b/etc/servers/work-server/.env.example @@ -0,0 +1,52 @@ +NODE_VERSION=22.22.2 +PORT=3100 +APP_TIME_ZONE=Asia/Seoul +DB_TIME_ZONE=Asia/Seoul +DB_CLIENT=pg +DB_HOST=work-db +DB_PORT=5432 +DB_NAME=work_db +DB_USER=work_user +DB_PASSWORD=change-me +DB_SSL=false +PLAN_WORKER_ENABLED=true +PLAN_WORKER_INTERVAL_MS=10000 +PLAN_WORKER_ID= +PLAN_GIT_REPO_PATH=/workspace/auto_codex/repo +PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project +PLAN_RELEASE_BRANCH=release +PLAN_MAIN_BRANCH=main +PLAN_GIT_USER_NAME=how2ice +PLAN_GIT_USER_EMAIL=how2ice@naver.com +PLAN_CODEX_RUNNER_PATH=/workspace/repo-scripts/run-plan-codex-once.mjs +PLAN_CODEX_ENABLED=true +PLAN_LOCAL_MAIN_MODE=true +PLAN_CODEX_BIN=codex +IOS_NOTIFICATION_ENABLED=false +WEB_PUSH_ENABLED=true +WEB_PUSH_VAPID_PUBLIC_KEY=BL1_f6BgOym_NhSs5QOmziKaYB5rTecl_2JG172w2AO_ru0hD-EG15S9F_6zgv0B6ajfzHEccgnwJAygfGDVv6Y +WEB_PUSH_VAPID_PRIVATE_KEY=BglyVgx-u1BnyFSIkTwbnamHnQDGxHewwmp5JLtyr3M +WEB_PUSH_SUBJECT=mailto:how2ice@naver.com +APNS_KEY_ID= +APNS_TEAM_ID= +APNS_BUNDLE_ID= +APNS_PRIVATE_KEY= +APNS_PRIVATE_KEY_PATH= +APNS_PRODUCTION=false +SERVER_COMMAND_ACCESS_TOKEN=usr_7f3a9c2d8e1b4a6f +SERVER_COMMAND_API_BASE_URL=http://host.docker.internal:3211/api +SERVER_COMMAND_API_ACCESS_TOKEN=local-server-command-runner +SERVER_COMMAND_API_RESTART_PATH_TEMPLATE=/api/server-commands/{key}/actions/restart +SERVER_COMMAND_PROJECT_ROOT=/workspace/auto_codex/repo +SERVER_COMMAND_MAIN_PROJECT_ROOT=/workspace/main-project +SERVER_COMMAND_DOCKER_SOCKET=/var/run/docker.sock +SERVER_COMMAND_TEST_URL=https://test.sm-home.cloud/ +SERVER_COMMAND_REL_URL=https://rel.sm-home.cloud/ +SERVER_COMMAND_WORK_SERVER_URL=http://127.0.0.1:3100/health +SERVER_COMMAND_RUNNER_URL=http://host.docker.internal:3211/health +SERVER_COMMAND_RUNNER_ACCESS_TOKEN=local-server-command-runner +SERVER_COMMAND_RUNNER_HOST=0.0.0.0 +SERVER_COMMAND_RUNNER_HEARTBEAT_FILE=/workspace/main-project/.server-command-runner-heartbeat.json +SERVER_COMMAND_TEST_SERVICE=app +SERVER_COMMAND_REL_SERVICE=release-app +SERVER_COMMAND_WORK_SERVER_SERVICE=work-server diff --git a/etc/servers/work-server/.gitignore b/etc/servers/work-server/.gitignore new file mode 100755 index 0000000..9c97bbd --- /dev/null +++ b/etc/servers/work-server/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/etc/servers/work-server/Dockerfile b/etc/servers/work-server/Dockerfile new file mode 100644 index 0000000..4f447cf --- /dev/null +++ b/etc/servers/work-server/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22.22.2-bookworm + +WORKDIR /app + +COPY package*.json ./ +RUN npm install -g @openai/codex && npm ci --legacy-peer-deps + +COPY tsconfig.json ./ +COPY scripts ./scripts +COPY src ./src + +RUN npm run build + +CMD ["npm", "run", "start"] diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md new file mode 100644 index 0000000..a04fe3e --- /dev/null +++ b/etc/servers/work-server/README.md @@ -0,0 +1,115 @@ +# Work Server + +`Fastify + Knex + PostgreSQL` ๊ธฐ๋ฐ˜์˜ ๋ฒ”์šฉ ์ž‘์—…์šฉ API ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค. + +## ์ถ”์ฒœ DB + +- `PostgreSQL` +- ์ด์œ : + - Node ์ƒํƒœ๊ณ„์—์„œ ๊ฒ€์ฆ๋œ ์กฐํ•ฉ + - `Knex`๋กœ CRUD์™€ DDL์„ ํ•จ๊ป˜ ๋‹ค๋ฃจ๊ธฐ ํŽธํ•จ + - ์šด์˜/ํ™•์žฅ/๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ธก๋ฉด์—์„œ ๋ฌด๋‚œํ•จ + +## ์‹คํ–‰ + +```bash +docker compose up -d +docker compose logs -f work-server +``` + +`work-server`๋Š” HMR/watch ์—†์ด ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ(`dist`)์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ์ปจํ…Œ์ด๋„ˆ ์žฌ๊ธฐ๋™์€ `docker compose up -d --build --force-recreate --no-deps work-server` ๊ธฐ์ค€์œผ๋กœ ์ตœ์‹  ์†Œ์Šค๋ฅผ ๋‹ค์‹œ ๋นŒ๋“œํ•œ ๋’ค ์ƒˆ ์ปจํ…Œ์ด๋„ˆ๋ฅผ ๋„์›๋‹ˆ๋‹ค. + +ํ˜ธ์ŠคํŠธ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์™€ ๋™์ผํ•œ ๋ฌธ๋งฅ์œผ๋กœ ์„œ๋ฒ„ ์žฌ๊ธฐ๋™์„ ์ฒ˜๋ฆฌํ•˜๋ ค๋ฉด ๋ณ„๋„ host runner๋„ ํ•จ๊ป˜ ์ผญ๋‹ˆ๋‹ค. + +```bash +cd /home/how2ice/project/ai-code-app +npm run server-command:runner +``` + +## ํ™˜๊ฒฝ ๋ณ€์ˆ˜ + +๊ธฐ๋ณธ ์‹คํ–‰์€ `.env.example` ๊ฐ’์œผ๋กœ๋„ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. +๋กœ์ปฌ ํ™˜๊ฒฝ์— ๋งž๋Š” ๊ฐ’์„ ๋ฎ์–ด์“ฐ๋ ค๋ฉด `.env.example`๋ฅผ ์ฐธ๊ณ ํ•ด์„œ `.env`๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. + +์ฃผ์š” ํ•ญ๋ชฉ: + +- `APP_TIME_ZONE`: Node ์„œ๋ฒ„ ๋Ÿฐํƒ€์ž„ ๊ธฐ์ค€ ์‹œ๊ฐ„๋Œ€. ๊ธฐ๋ณธ๊ฐ’ `Asia/Seoul` +- `DB_TIME_ZONE`: ์•ฑ์ด ์—ฌ๋Š” DB ์„ธ์…˜ ์‹œ๊ฐ„๋Œ€. ๊ธฐ๋ณธ๊ฐ’ `Asia/Seoul` +- `DB_*`: PostgreSQL ์ ‘์† ์ •๋ณด +- `PLAN_WORKER_ENABLED`: Plan ์ž๋™ํ™” worker ํ™œ์„ฑํ™” ์—ฌ๋ถ€ +- `PLAN_WORKER_INTERVAL_MS`: Plan polling ์ฃผ๊ธฐ +- `PLAN_GIT_REPO_PATH`: ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ/๋ณ‘ํ•ฉ ๋Œ€์ƒ ์ €์žฅ์†Œ ๊ฒฝ๋กœ +- `PLAN_MAIN_PROJECT_REPO_PATH`: main ๋ฐ˜์˜ ํ›„ pull ๋ฐ›์„ ๋ฉ”์ธ ๋ฃจํŠธ ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ. ๋น„์šฐ๋ฉด `PLAN_GIT_REPO_PATH`๋ฅผ ์‚ฌ์šฉ +- `PLAN_RELEASE_BRANCH`: ์ž๋™ merge ๋Œ€์ƒ release ๋ธŒ๋žœ์น˜๋ช… +- `IOS_NOTIFICATION_ENABLED`: iOS APNs ์•Œ๋ฆผ ํ™œ์„ฑํ™” ์—ฌ๋ถ€ +- `APNS_*`: Apple Push Notification ์ธ์ฆ ํ‚ค ์ •๋ณด +- `SERVER_COMMAND_DOCKER_SOCKET`: ์„œ๋ฒ„ ์žฌ๊ธฐ๋™ ๋ช…๋ น์ด ์‚ฌ์šฉํ•  Docker Unix socket ๊ฒฝ๋กœ. rootless Docker๋ฉด ์˜ˆ: `/run/user/1000/docker.sock` +- `SERVER_COMMAND_API_BASE_URL`: `work-server`๊ฐ€ ์„œ๋ฒ„ ์žฌ๊ธฐ๋™ ์š”์ฒญ์„ ์œ„์ž„ํ•  host runner ์ฃผ์†Œ +- `SERVER_COMMAND_API_ACCESS_TOKEN`: host runner ํ˜ธ์ถœ ํ† ํฐ + +์„œ๋ฒ„ ์žฌ๊ธฐ๋™ ๊ธฐ๋Šฅ์„ ์“ฐ๋ ค๋ฉด `work-server` ์ปจํ…Œ์ด๋„ˆ๊ฐ€ Docker์— ์ ‘๊ทผํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ `/var/run/docker.sock`์ด๋ฉฐ, rootless Docker ํ™˜๊ฒฝ์ด๋ฉด `.env`์— `SERVER_COMMAND_DOCKER_SOCKET` ๋˜๋Š” `DOCKER_HOST=unix:///run/user//docker.sock`๋ฅผ ๋งž์ถฐ ์ค€ ๋’ค `work-server`๋ฅผ ๋‹ค์‹œ ์˜ฌ๋ ค์•ผ ํ•ฉ๋‹ˆ๋‹ค. + +๊ธฐ๋ณธ ์˜ˆ์‹œ๋Š” `http://host.docker.internal:3211/api`๋กœ ๋งž์ถฐ์ ธ ์žˆ์–ด์„œ, `work-server` ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ์•„๋‹ˆ๋ผ ํ˜ธ์ŠคํŠธ์˜ ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์—์„œ `restart-*.sh`๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. ์ฆ‰ `Server > Command`๊ฐ€ ์ง์ ‘ CLI๋กœ ์žฌ๊ธฐ๋™ํ•œ ๊ฒƒ๊ณผ ์ตœ๋Œ€ํ•œ ๋น„์Šทํ•œ ๋ฌธ๋งฅ์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +## Codex Live + +`Codex Live`๋Š” ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ™˜๊ฒฝ์˜ `main_project` ๊ฒฝ๋กœ๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ `PLAN_MAIN_PROJECT_REPO_PATH=/workspace/main-project`์ด๋ฉฐ, ์†Œ์Šค ์ˆ˜์ •์ด ํ•„์š”ํ•˜๋ฉด ์ด ๊ฒฝ๋กœ์˜ ์‹ค์ œ ํ”„๋กœ์ ํŠธ๋ฅผ ๋ฐ”๋กœ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค. + +ํ˜„์žฌ ์šด์˜ ๊ธฐ์ค€์—์„œ๋Š” `Codex Live`, ์ผ๋ฐ˜ ์ฑ„ํŒ…, ์ž‘์—…๋ฉ”๋ชจ ๋ฐ˜์˜ ์š”์ฒญ ๋ชจ๋‘ **ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ์˜ ๋กœ์ปฌ `main` ์ž‘์—…๋ณธ์„ ๋ฐ”๋กœ ์ˆ˜์ •**ํ•ฉ๋‹ˆ๋‹ค. ๋ณ„๋„ ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ์ด๋‚˜ `release -> main` ๋™๊ธฐํ™”๋Š” ๊ธฐ๋ณธ ์ „์ œ๋กœ ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฉฐ, Git ๊ด€๋ จ ์ž‘์—…์€ ์‚ฌ์šฉ์ž๊ฐ€ ๋ช…์‹œ์ ์œผ๋กœ ์š”์ฒญํ•  ๋•Œ๋งŒ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. + +๋ธŒ๋ผ์šฐ์ € ๊ธฐ์ค€ ์ ‘์† ํ™•์ธ, ํ™”๋ฉด ๊ฒ€์ฆ, ์™ธ๋ถ€ ๋„๋ฉ”์ธ ํ…Œ์ŠคํŠธ๋Š” **`https://test.sm-home.cloud/`๋ฅผ ๊ธฐ๋ณธ ์ž‘์—… ๋„๋ฉ”์ธ์œผ๋กœ ์‚ฌ์šฉ**ํ•ฉ๋‹ˆ๋‹ค. ๋ณ„๋„ ์š”์ฒญ์ด ์—†๋Š” ํ•œ `sm-home.cloud`๋‚˜ `rel.sm-home.cloud`๋Š” ๊ธฐ๋ณธ ๊ฒ€์ฆ ๋Œ€์ƒ์œผ๋กœ ์‚ผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. + +์ฑ„ํŒ…์—์„œ ํŒŒ์ผ, ๋ฌธ์„œ, ์ด๋ฏธ์ง€, ์ฝ”๋“œ ๊ฐ™์€ ๋ฆฌ์†Œ์Šค๋ฅผ ์ œ๊ณตํ•  ๋•Œ์˜ ๊ธฐ๋ณธ ๊ณต๊ฐœ ๊ฒฝ๋กœ๋Š” `public/.codex_chat//resource/...`์ž…๋‹ˆ๋‹ค. Codex๊ฐ€ ์›๋ณธ ํŒŒ์ผ ๊ฒฝ๋กœ๋งŒ ๋‹ตํ•ด๋„ ์„œ๋ฒ„๊ฐ€ ์ด ์œ„์น˜๋กœ ์„ธ์…˜ ์ „์šฉ ์‚ฌ๋ณธ์„ ๋งŒ๋“ค๊ณ , ์ฑ„ํŒ…์—๋Š” ๊ณต๊ฐœ URL์„ ๋‹ค์‹œ ์ ์–ด ์ค๋‹ˆ๋‹ค. + +์ฑ„ํŒ… ์ฒจ๋ถ€ ํŒŒ์ผ๋„ ๊ฐ™์€ ๊ธฐ์ค€์„ ์‚ฌ์šฉํ•˜๋ฉฐ `public/.codex_chat//resource/uploads/...` ์•„๋ž˜์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค. + +## Plan ์ž๋™ํ™” + +`Plan` ๊ฒŒ์‹œํŒ ํ•ญ๋ชฉ์„ ์ž‘์—… ํ์ฒ˜๋Ÿผ ์ฝ์–ด ์ž๋™ํ™”ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + +ํ˜„์žฌ ๋กœ์ปฌ ์šด์˜ ๋ชจ๋“œ์—์„œ๋Š” ์•„๋ž˜ ์ž๋™ ๋ธŒ๋žœ์น˜ ํ๋ฆ„์„ ๊ธฐ๋ณธ ๋™์ž‘์œผ๋กœ ๊ฐ•์ œํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•„์š” ์‹œ ์‚ฌ์šฉ์ž๊ฐ€ ๋ณ„๋„๋กœ ์š”์ฒญํ•œ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + +- `๋“ฑ๋ก` ์ƒํƒœ: worker๊ฐ€ ์ฝ์–ด์„œ `feature/plan-{id}-{workId}` ๋ธŒ๋žœ์น˜ ์ƒ์„ฑ ์‹œ๋„ +- ์„ฑ๊ณต ์‹œ: `์ž‘์—…์ค‘`, `๋ธŒ๋žœ์น˜์ค€๋น„` +- ์‹คํŒจ ์‹œ: `์ด์Šˆ`, ์ตœ๊ทผ ์˜ค๋ฅ˜ ๊ธฐ๋ก +- `๊ฐœ๋ฐœ์™„๋ฃŒ` ์ƒํƒœ: worker๊ฐ€ `release` ๋ธŒ๋žœ์น˜ ๋ณ‘ํ•ฉ ์‹œ๋„ +- ๋ณ‘ํ•ฉ ์„ฑ๊ณต ์‹œ: `์™„๋ฃŒ` +- ๋ณ‘ํ•ฉ ์‹คํŒจ ์‹œ: `์ด์Šˆ` + +์•ˆ์ „ ์กฐ๊ฑด: + +- Git worktree๊ฐ€ ๊นจ๋—ํ•ด์•ผ ๋™์ž‘ +- `release` ๋ธŒ๋žœ์น˜๊ฐ€ ์‹ค์ œ๋กœ ์กด์žฌํ•ด์•ผ ๋ณ‘ํ•ฉ ๊ฐ€๋Šฅ +- ์‹คํŒจ ์‹œ ์ž๋™์œผ๋กœ `์ด์Šˆ` ์ƒํƒœ์™€ ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ ๋‚จ๊น€ + +## ์ฃผ์š” API + +- `GET /health` +- `GET /api/schema/tables` +- `POST /api/ddl/create-table` +- `POST /api/ddl/drop-table` +- `POST /api/ddl/add-column` +- `POST /api/ddl/drop-column` +- `POST /api/ddl/raw` +- `POST /api/crud/:table/select` +- `POST /api/crud/:table/insert` +- `PATCH /api/crud/:table/update` +- `DELETE /api/crud/:table/delete` +- `GET /api/plan/statuses` +- `POST /api/plan/setup` +- `GET /api/plan/items` +- `GET /api/plan/items/:id` +- `POST /api/plan/items` +- `PATCH /api/plan/items/:id` +- `DELETE /api/plan/items/:id` +- `POST /api/notifications/setup` +- `GET /api/notifications/tokens` +- `PUT /api/notifications/tokens/ios` +- `DELETE /api/notifications/tokens/ios` +- `POST /api/notifications/send-test` + +## iOS ์•Œ๋ฆผ ์—ฐ๋™ + +- ํ”„๋ก ํŠธ์—์„œ ์•Œ๋ฆผ `On` ์‹œ `PUT /api/notifications/tokens/ios`๋กœ APNs ํ† ํฐ์„ ๋“ฑ๋กํ•ฉ๋‹ˆ๋‹ค. +- ํ”„๋ก ํŠธ์—์„œ ์•Œ๋ฆผ `Off` ์‹œ ๋˜๋Š” ํ† ํฐ์ด ํ๊ธฐ๋˜๋ฉด `DELETE /api/notifications/tokens/ios`๋กœ ํ† ํฐ์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. +- Plan worker๊ฐ€ ๋ธŒ๋žœ์น˜ ์ค€๋น„, ์ž๋™ ์ž‘์—… ์™„๋ฃŒ/์‹คํŒจ, release ๋ฐ˜์˜ ์™„๋ฃŒ/์‹คํŒจ, main ๋ฐ˜์˜ ์™„๋ฃŒ/์‹คํŒจ ์‹œ ๋“ฑ๋ก๋œ iOS ํ† ํฐ์œผ๋กœ APNs ์•Œ๋ฆผ์„ ์ „์†กํ•ฉ๋‹ˆ๋‹ค. diff --git a/etc/servers/work-server/docker-compose.yml b/etc/servers/work-server/docker-compose.yml new file mode 100644 index 0000000..b54f494 --- /dev/null +++ b/etc/servers/work-server/docker-compose.yml @@ -0,0 +1,53 @@ +services: + work-server: + build: + context: . + dockerfile: Dockerfile + container_name: work-server + logging: + driver: json-file + options: + max-size: "200m" + max-file: "2" + user: "0:0" + group_add: + - "${SERVER_COMMAND_DOCKER_GID:-984}" + cpus: 1.5 + mem_limit: 2048m + working_dir: /app + env_file: + - path: ./.env.example + required: false + - path: ./.env + required: false + ports: + - '127.0.0.1:3100:3100' + volumes: + - ../../../:/workspace/main-project + - ../../../.auto_codex:/workspace/auto_codex + - ../../../scripts:/workspace/repo-scripts:ro + - ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}:${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock} + - ./.docker/home:/home/how2ice + - ./.docker/codex-home:/codex-home + - ./.docker/codex-home-template:/codex-home-template + environment: + TZ: ${APP_TIME_ZONE:-Asia/Seoul} + HOME: /home/how2ice + CODEX_HOME: /codex-home + PLAN_CODEX_TEMPLATE_HOME: /codex-home-template + PLAN_CODEX_BIN: ${PLAN_CODEX_BIN:-codex} + PLAN_CODEX_ENABLED: ${PLAN_CODEX_ENABLED:-false} + PLAN_WORKER_ENABLED: ${PLAN_WORKER_ENABLED:-false} + APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Seoul} + DB_TIME_ZONE: ${DB_TIME_ZONE:-Asia/Seoul} + NPM_CONFIG_CACHE: /home/how2ice/.npm + SERVER_COMMAND_DOCKER_SOCKET: ${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock} + DOCKER_HOST: ${DOCKER_HOST:-} + networks: + - work-backend + extra_hosts: + - 'host.docker.internal:host-gateway' + +networks: + work-backend: + name: work-backend diff --git a/etc/servers/work-server/package-lock.json b/etc/servers/work-server/package-lock.json new file mode 100755 index 0000000..6d21cea --- /dev/null +++ b/etc/servers/work-server/package-lock.json @@ -0,0 +1,1999 @@ +{ + "name": "work-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "work-server", + "version": "0.1.0", + "dependencies": { + "@fastify/cors": "^11.1.0", + "@parse/node-apn": "^8.0.0", + "dotenv": "^17.2.2", + "fastify": "^5.6.0", + "knex": "^3.1.0", + "pg": "^8.16.3", + "web-push": "^3.6.7", + "ws": "^8.18.3", + "zod": "^4.1.5" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/ws": "^8.18.1", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz", + "integrity": "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.5.tgz", + "integrity": "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.5.tgz", + "integrity": "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.5.tgz", + "integrity": "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.5.tgz", + "integrity": "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.5.tgz", + "integrity": "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.5.tgz", + "integrity": "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.5.tgz", + "integrity": "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.5.tgz", + "integrity": "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.5.tgz", + "integrity": "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.5.tgz", + "integrity": "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.5.tgz", + "integrity": "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.5.tgz", + "integrity": "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.5.tgz", + "integrity": "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.5.tgz", + "integrity": "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.5.tgz", + "integrity": "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.5.tgz", + "integrity": "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.5.tgz", + "integrity": "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.5.tgz", + "integrity": "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.5.tgz", + "integrity": "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.5.tgz", + "integrity": "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.5.tgz", + "integrity": "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.5.tgz", + "integrity": "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.5.tgz", + "integrity": "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.5.tgz", + "integrity": "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.5.tgz", + "integrity": "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@parse/node-apn": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@parse/node-apn/-/node-apn-8.0.0.tgz", + "integrity": "sha512-blvU/V0FL3j7u2lstso1aInMw7yYrKg/6Ctr3Kc/7kleFatAfZswhzHk9d5lI4DUQBsUBun8nidgZHCY6sft+Q==", + "license": "MIT", + "dependencies": { + "debug": "4.4.3", + "jsonwebtoken": "9.0.3", + "node-forge": "1.4.0", + "verror": "1.10.1" + }, + "engines": { + "node": "20 || 22 || 24" + } + }, + "node_modules/@parse/node-apn/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@parse/node-apn/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz", + "integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.27.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz", + "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.5", + "@esbuild/android-arm": "0.27.5", + "@esbuild/android-arm64": "0.27.5", + "@esbuild/android-x64": "0.27.5", + "@esbuild/darwin-arm64": "0.27.5", + "@esbuild/darwin-x64": "0.27.5", + "@esbuild/freebsd-arm64": "0.27.5", + "@esbuild/freebsd-x64": "0.27.5", + "@esbuild/linux-arm": "0.27.5", + "@esbuild/linux-arm64": "0.27.5", + "@esbuild/linux-ia32": "0.27.5", + "@esbuild/linux-loong64": "0.27.5", + "@esbuild/linux-mips64el": "0.27.5", + "@esbuild/linux-ppc64": "0.27.5", + "@esbuild/linux-riscv64": "0.27.5", + "@esbuild/linux-s390x": "0.27.5", + "@esbuild/linux-x64": "0.27.5", + "@esbuild/netbsd-arm64": "0.27.5", + "@esbuild/netbsd-x64": "0.27.5", + "@esbuild/openbsd-arm64": "0.27.5", + "@esbuild/openbsd-x64": "0.27.5", + "@esbuild/openharmony-arm64": "0.27.5", + "@esbuild/sunos-x64": "0.27.5", + "@esbuild/win32-arm64": "0.27.5", + "@esbuild/win32-ia32": "0.27.5", + "@esbuild/win32-x64": "0.27.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz", + "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getopts": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz", + "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/knex": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/knex/-/knex-3.2.8.tgz", + "integrity": "sha512-ElXXxu9Nq+5hWYdBUddYIWIT5yKKs5KNCsmKGbJSHPyaMpAABp3xs4L55GgdQoAs6QQ7dv72ai3M4pxYQ8utEg==", + "license": "MIT", + "dependencies": { + "colorette": "2.0.19", + "commander": "^10.0.0", + "debug": "4.3.4", + "escalade": "^3.1.1", + "esm": "^3.2.25", + "get-package-type": "^0.1.0", + "getopts": "2.3.0", + "interpret": "^2.2.0", + "lodash": "^4.17.21", + "pg-connection-string": "2.6.2", + "rechoir": "^0.8.0", + "resolve-from": "^5.0.0", + "tarn": "^3.0.2", + "tildify": "2.0.0" + }, + "bin": { + "knex": "bin/cli.js" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "pg-query-stream": "^4.14.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "mysql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz", + "integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tarn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", + "integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tildify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz", + "integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/etc/servers/work-server/package.json b/etc/servers/work-server/package.json new file mode 100644 index 0000000..9225408 --- /dev/null +++ b/etc/servers/work-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "work-server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "npm run build && npm run start", + "build": "tsc -p tsconfig.json && node ./scripts/write-build-info.mjs", + "start": "node dist/server.js", + "backfill:chat-request-links": "node --import tsx ./scripts/backfill-chat-request-links.ts", + "backfill:chat-resource-urls": "node --import tsx ./scripts/backfill-chat-resource-urls.ts", + "test": "node --import tsx --test src/**/*.test.ts" + }, + "dependencies": { + "@fastify/cors": "^11.1.0", + "@parse/node-apn": "^8.0.0", + "dotenv": "^17.2.2", + "fastify": "^5.6.0", + "knex": "^3.1.0", + "pg": "^8.16.3", + "web-push": "^3.6.7", + "ws": "^8.18.3", + "zod": "^4.1.5" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/ws": "^8.18.1", + "tsx": "^4.20.5", + "typescript": "^5.9.2" + } +} diff --git a/etc/servers/work-server/scripts/backfill-chat-request-links.ts b/etc/servers/work-server/scripts/backfill-chat-request-links.ts new file mode 100644 index 0000000..77f0de5 --- /dev/null +++ b/etc/servers/work-server/scripts/backfill-chat-request-links.ts @@ -0,0 +1,14 @@ +import { db } from '../src/db/client.js'; +import { repairChatConversationRequestLinks } from '../src/services/chat-room-service.js'; + +const requestedSessionId = process.argv[2]?.trim() || null; + +try { + const result = await repairChatConversationRequestLinks(requestedSessionId); + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + await db.destroy(); +} diff --git a/etc/servers/work-server/scripts/backfill-chat-resource-urls.ts b/etc/servers/work-server/scripts/backfill-chat-resource-urls.ts new file mode 100644 index 0000000..9ce0645 --- /dev/null +++ b/etc/servers/work-server/scripts/backfill-chat-resource-urls.ts @@ -0,0 +1,76 @@ +import { db } from '../src/db/client.js'; +import { + CHAT_CONVERSATION_MESSAGE_TABLE, + CHAT_CONVERSATION_REQUEST_TABLE, +} from '../src/services/chat-room-service.js'; + +const LEGACY_CHAT_RESOURCE_PREFIX = '/.codex_chat/'; +const API_CHAT_RESOURCE_PREFIX = '/api/chat/resources/.codex_chat/'; +const requestedSessionId = process.argv[2]?.trim() || null; + +function rewriteLegacyChatResourceUrls(text: string) { + const normalized = String(text ?? '').replaceAll(LEGACY_CHAT_RESOURCE_PREFIX, API_CHAT_RESOURCE_PREFIX); + + return normalized.replace( + /\((?:\/[^)\s]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^)\s]*?)(?:\/api\/chat\/resources\/\.codex_chat\/[^)\s]*)?\)/g, + (_match, resourcePath) => `(${resourcePath})`, + ); +} + +async function backfillTable( + tableName: string, + textColumn: string, +) { + const rows = await db(tableName) + .modify((query) => { + if (requestedSessionId) { + query.where('session_id', requestedSessionId); + } + }) + .where(textColumn, 'like', `%${LEGACY_CHAT_RESOURCE_PREFIX}%`) + .select('id', 'session_id', textColumn); + + let updatedCount = 0; + const touchedSessionIds = new Set(); + + for (const row of rows) { + const currentText = String(row[textColumn] ?? ''); + const nextText = rewriteLegacyChatResourceUrls(currentText); + + if (nextText === currentText) { + continue; + } + + await db(tableName) + .where('id', row.id) + .update({ + [textColumn]: nextText, + }); + + updatedCount += 1; + touchedSessionIds.add(String(row.session_id ?? '')); + } + + return { + tableName, + textColumn, + updatedCount, + touchedSessionIds: Array.from(touchedSessionIds).filter(Boolean), + }; +} + +try { + const messageResult = await backfillTable(CHAT_CONVERSATION_MESSAGE_TABLE, 'text'); + const requestResult = await backfillTable(CHAT_CONVERSATION_REQUEST_TABLE, 'response_text'); + + console.log(JSON.stringify({ + requestedSessionId, + updatedRowCount: messageResult.updatedCount + requestResult.updatedCount, + tables: [messageResult, requestResult], + }, null, 2)); +} catch (error) { + console.error(error); + process.exitCode = 1; +} finally { + await db.destroy(); +} diff --git a/etc/servers/work-server/scripts/write-build-info.mjs b/etc/servers/work-server/scripts/write-build-info.mjs new file mode 100755 index 0000000..8926af1 --- /dev/null +++ b/etc/servers/work-server/scripts/write-build-info.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const projectRoot = process.cwd(); +const packageJsonPath = path.join(projectRoot, 'package.json'); +const distDirectoryPath = path.join(projectRoot, 'dist'); +const buildInfoPath = path.join(distDirectoryPath, 'build-info.json'); + +const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); +const builtAt = new Date().toISOString(); + +const buildInfo = { + version: typeof packageJson.version === 'string' ? packageJson.version : '0.0.0', + buildId: `${typeof packageJson.version === 'string' ? packageJson.version : '0.0.0'}@${builtAt}`, + builtAt, +}; + +await fs.mkdir(distDirectoryPath, { recursive: true }); +await fs.writeFile(buildInfoPath, JSON.stringify(buildInfo, null, 2)); + +console.log(`work-server build info written to ${buildInfoPath}`); diff --git a/etc/servers/work-server/src/app.ts b/etc/servers/work-server/src/app.ts new file mode 100755 index 0000000..ae04a40 --- /dev/null +++ b/etc/servers/work-server/src/app.ts @@ -0,0 +1,104 @@ +import cors from '@fastify/cors'; +import Fastify from 'fastify'; +import { registerJsonBodyParser } from './json-body.js'; +import { registerBoardRoutes } from './routes/board.js'; +import { registerCrudRoutes } from './routes/crud.js'; +import { registerDdlRoutes } from './routes/ddl.js'; +import { registerErrorLogRoutes } from './routes/error-log.js'; +import { registerHealthRoutes } from './routes/health.js'; +import { registerAppConfigRoutes } from './routes/app-config.js'; +import { registerChatRoutes } from './routes/chat.js'; +import { registerNotificationRoutes } from './routes/notification.js'; +import { registerPlanRoutes } from './routes/plan.js'; +import { registerServerCommandRoutes } from './routes/server-command.js'; +import { registerSchemaRoutes } from './routes/schema.js'; +import { registerVisitorHistoryRoutes } from './routes/visitor-history.js'; +import { shouldPersistNotFoundErrorLog } from './not-found.js'; +import { createErrorLog } from './services/error-log-service.js'; + +export function createApp() { + const app = Fastify({ + logger: true, + }); + + app.register(cors, { + origin: true, + }); + + registerJsonBodyParser(app); + app.register(registerBoardRoutes); + app.register(registerHealthRoutes); + app.register(registerAppConfigRoutes); + app.register(registerChatRoutes); + app.register(registerSchemaRoutes); + app.register(registerDdlRoutes); + app.register(registerCrudRoutes); + app.register(registerErrorLogRoutes); + app.register(registerNotificationRoutes); + app.register(registerPlanRoutes); + app.register(registerServerCommandRoutes); + app.register(registerVisitorHistoryRoutes); + + app.setNotFoundHandler(async (request, reply) => { + if (shouldPersistNotFoundErrorLog(request.url)) { + try { + await createErrorLog({ + source: 'server', + sourceLabel: '์›Œํฌ์„œ๋ฒ„ API', + errorType: 'NotFound', + errorMessage: `Route not found: ${request.method} ${request.url}`, + statusCode: 404, + requestMethod: request.method, + requestPath: request.url, + context: { + route: request.routeOptions.url, + }, + }); + } catch (loggingError) { + app.log.error(loggingError, 'Failed to persist 404 error log'); + } + } + + reply.status(404); + return { + message: '์š”์ฒญํ•œ ๊ฒฝ๋กœ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }; + }); + + app.setErrorHandler(async (error, request, reply) => { + const handledError = error instanceof Error ? error : new Error(String(error)); + const errorWithMeta = handledError as Error & { statusCode?: number; code?: string }; + const statusCode = typeof errorWithMeta.statusCode === 'number' + ? Number(errorWithMeta.statusCode) + : 500; + + try { + await createErrorLog({ + source: 'server', + sourceLabel: '์›Œํฌ์„œ๋ฒ„ API', + errorType: errorWithMeta.code ?? handledError.name ?? 'ServerError', + errorName: handledError.name, + errorMessage: handledError.message || '์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + stackTrace: handledError.stack, + statusCode, + requestMethod: request.method, + requestPath: request.url, + context: { + route: request.routeOptions.url, + params: (request.params as Record | undefined) ?? null, + query: (request.query as Record | undefined) ?? null, + }, + }); + } catch (loggingError) { + app.log.error(loggingError, 'Failed to persist server error log'); + } + + app.log.error(handledError); + reply.status(statusCode >= 400 ? statusCode : 500); + return { + message: handledError.message || '์š”์ฒญ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + }); + + return app; +} diff --git a/etc/servers/work-server/src/config/env.ts b/etc/servers/work-server/src/config/env.ts new file mode 100644 index 0000000..c9f686e --- /dev/null +++ b/etc/servers/work-server/src/config/env.ts @@ -0,0 +1,103 @@ +import path from 'node:path'; +import dotenv from 'dotenv'; +import { z } from 'zod'; + +dotenv.config({ override: true, quiet: true }); + +const envSchema = z.object({ + PORT: z.coerce.number().default(3100), + APP_TIME_ZONE: z.string().default('Asia/Seoul'), + DB_TIME_ZONE: z.string().default('Asia/Seoul'), + DB_CLIENT: z.string().default('pg'), + DB_HOST: z.string().default('localhost'), + DB_PORT: z.coerce.number().default(5432), + DB_NAME: z.string().default('work_db'), + DB_USER: z.string().default('work_user'), + DB_PASSWORD: z.string().default('change-me'), + DB_SSL: z + .string() + .default('false') + .transform((value) => value === 'true'), + PLAN_WORKER_ENABLED: z + .string() + .default('true') + .transform((value) => value === 'true'), + PLAN_WORKER_INTERVAL_MS: z.coerce.number().default(10000), + PLAN_WORKER_ID: z.string().optional(), + PLAN_GIT_REPO_PATH: z.string().default('/workspace/repo'), + PLAN_MAIN_PROJECT_REPO_PATH: z.string().optional(), + PLAN_RELEASE_BRANCH: z.string().default('release'), + PLAN_MAIN_BRANCH: z.string().default('main'), + PLAN_GIT_USER_NAME: z.string().default('how2ice'), + PLAN_GIT_USER_EMAIL: z.string().default('how2ice@naver.com'), + PLAN_CODEX_RUNNER_PATH: z.string().default('/workspace/repo-scripts/run-plan-codex-once.mjs'), + PLAN_CODEX_ENABLED: z + .string() + .default('true') + .transform((value) => value === 'true'), + PLAN_LOCAL_MAIN_MODE: z + .string() + .default('true') + .transform((value) => value === 'true'), + PLAN_CODEX_BIN: z.string().default('codex'), + PLAN_CODEX_TEMPLATE_HOME: z.string().optional(), + PLAN_PREVIEW_BASE_URL: z.string().optional(), + PLAN_PREVIEW_URL_TEMPLATE: z.string().optional(), + IOS_NOTIFICATION_ENABLED: z + .string() + .default('false') + .transform((value) => value === 'true'), + WEB_PUSH_ENABLED: z + .string() + .default('false') + .transform((value) => value === 'true'), + WEB_PUSH_VAPID_PUBLIC_KEY: z.string().optional(), + WEB_PUSH_VAPID_PRIVATE_KEY: z.string().optional(), + WEB_PUSH_SUBJECT: z.string().default('mailto:how2ice@naver.com'), + APNS_KEY_ID: z.string().optional(), + APNS_TEAM_ID: z.string().optional(), + APNS_BUNDLE_ID: z.string().optional(), + APNS_PRIVATE_KEY: z.string().optional(), + APNS_PRIVATE_KEY_PATH: z.string().optional(), + APNS_PRODUCTION: z + .string() + .default('false') + .transform((value) => value === 'true'), + SERVER_COMMAND_ACCESS_TOKEN: z.string().default('usr_7f3a9c2d8e1b4a6f'), + SERVER_COMMAND_API_BASE_URL: z.string().optional(), + SERVER_COMMAND_API_ACCESS_TOKEN: z.string().optional(), + SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: z.string().default('/api/server-commands/{key}/actions/restart'), + SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')), + SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'), + SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'), + SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'), + SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'), + SERVER_COMMAND_RUNNER_URL: z.string().default('http://host.docker.internal:3211/health'), + SERVER_COMMAND_RUNNER_ACCESS_TOKEN: z.string().default('local-server-command-runner'), + SERVER_COMMAND_RUNNER_HEARTBEAT_FILE: z.string().optional(), + SERVER_COMMAND_TEST_SERVICE: z.string().default('app'), + SERVER_COMMAND_REL_SERVICE: z.string().default('release-app'), + SERVER_COMMAND_WORK_SERVER_SERVICE: z.string().default('work-server'), +}); + +function parseEnv() { + dotenv.config({ override: true, quiet: true }); + const parsedEnv = envSchema.parse(process.env); + + return { + ...parsedEnv, + PLAN_MAIN_PROJECT_REPO_PATH: parsedEnv.PLAN_MAIN_PROJECT_REPO_PATH ?? parsedEnv.PLAN_GIT_REPO_PATH, + }; +} + +export function getEnv() { + const parsedEnv = parseEnv(); + + if (!process.env.TZ?.trim()) { + process.env.TZ = parsedEnv.APP_TIME_ZONE; + } + + return parsedEnv; +} + +export const env = getEnv(); diff --git a/etc/servers/work-server/src/db/client.ts b/etc/servers/work-server/src/db/client.ts new file mode 100755 index 0000000..9b00be4 --- /dev/null +++ b/etc/servers/work-server/src/db/client.ts @@ -0,0 +1,37 @@ +import knex from 'knex'; +import { env } from '../config/env.js'; + +export const db = knex({ + client: env.DB_CLIENT, + connection: { + host: env.DB_HOST, + port: env.DB_PORT, + database: env.DB_NAME, + user: env.DB_USER, + password: env.DB_PASSWORD, + ssl: env.DB_SSL ? { rejectUnauthorized: false } : false, + }, + pool: { + min: 0, + max: 10, + afterCreate(connection: any, done: (error: Error | null, connection: any) => void) { + const clientName = String(env.DB_CLIENT ?? '').toLowerCase(); + + if (clientName === 'pg' || clientName === 'postgres' || clientName === 'postgresql') { + connection.query(`SET TIME ZONE '${env.DB_TIME_ZONE}'`, (error: Error | null) => { + done(error, connection); + }); + return; + } + + if (clientName === 'mysql' || clientName === 'mysql2') { + connection.query('SET time_zone = "+09:00"', (error: Error | null) => { + done(error, connection); + }); + return; + } + + done(null, connection); + }, + }, +}); diff --git a/etc/servers/work-server/src/json-body.ts b/etc/servers/work-server/src/json-body.ts new file mode 100755 index 0000000..bb833e0 --- /dev/null +++ b/etc/servers/work-server/src/json-body.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance } from 'fastify'; + +export function registerJsonBodyParser(app: FastifyInstance) { + app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => { + const rawBody = typeof body === 'string' ? body : ''; + const normalizedBody = rawBody.trim(); + + if (!normalizedBody) { + done(null, {}); + return; + } + + try { + done(null, JSON.parse(normalizedBody)); + } catch { + const error = new Error('Body is not valid JSON.') as Error & { + statusCode?: number; + code?: string; + }; + error.statusCode = 400; + error.code = 'FST_ERR_CTP_INVALID_JSON_BODY'; + done(error, undefined); + } + }); +} diff --git a/etc/servers/work-server/src/lib/identifier.ts b/etc/servers/work-server/src/lib/identifier.ts new file mode 100755 index 0000000..cf12f42 --- /dev/null +++ b/etc/servers/work-server/src/lib/identifier.ts @@ -0,0 +1,9 @@ +const IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +export function assertIdentifier(value: string, label = 'identifier') { + if (!IDENTIFIER_PATTERN.test(value)) { + throw new Error(`Invalid ${label}: ${value}`); + } + + return value; +} diff --git a/etc/servers/work-server/src/not-found.test.ts b/etc/servers/work-server/src/not-found.test.ts new file mode 100755 index 0000000..1e67c1f --- /dev/null +++ b/etc/servers/work-server/src/not-found.test.ts @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { shouldPersistNotFoundErrorLog } from './not-found.js'; + +test('shouldPersistNotFoundErrorLog only keeps work-server API paths', () => { + assert.equal(shouldPersistNotFoundErrorLog('/api'), true); + assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation'), true); + assert.equal(shouldPersistNotFoundErrorLog('/api/notifications/preferences/automation?targetKind=client&targetId=abc'), true); + assert.equal(shouldPersistNotFoundErrorLog('/api/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/1234567890abcdef1234567890abcdef'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/docs'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/docs/index.html'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/env'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/config'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/debug'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/debug/pprof'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/.env'), false); + assert.equal(shouldPersistNotFoundErrorLog('/api/.git/config'), false); + assert.equal(shouldPersistNotFoundErrorLog('/apis/components'), false); + assert.equal(shouldPersistNotFoundErrorLog('/apis/widgets?widgetId=dashboard-report-card'), false); + assert.equal(shouldPersistNotFoundErrorLog('/plans/release-review'), false); +}); diff --git a/etc/servers/work-server/src/not-found.ts b/etc/servers/work-server/src/not-found.ts new file mode 100755 index 0000000..9ba2a0a --- /dev/null +++ b/etc/servers/work-server/src/not-found.ts @@ -0,0 +1,61 @@ +function normalizeRequestPath(requestUrl: string) { + try { + return new URL(requestUrl, 'http://localhost').pathname; + } catch { + return requestUrl.split('?')[0] ?? requestUrl; + } +} + +function hasHiddenPathSegment(pathname: string) { + return pathname + .split('/') + .some((segment, index) => index > 1 && segment.startsWith('.')); +} + +function isIgnoredScannerProbePath(pathname: string) { + return pathname === '/api/docs' || + pathname.startsWith('/api/docs/') || + pathname === '/api/env' || + pathname === '/api/config' || + pathname === '/api/debug' || + pathname.startsWith('/api/debug/'); +} + +function isOpaqueScannerToken(segment: string) { + if (segment.length < 24 || !/^[a-z0-9_-]+$/i.test(segment)) { + return false; + } + + if (/^(.)\1+$/.test(segment)) { + return true; + } + + return !/[/-]/.test(segment); +} + +function isIgnoredOpaqueApiProbePath(pathname: string) { + const segments = pathname.split('/').filter(Boolean); + + if (segments.length !== 2 || segments[0] !== 'api') { + return false; + } + + return isOpaqueScannerToken(segments[1] ?? ''); +} + +export function shouldPersistNotFoundErrorLog(requestUrl: string) { + const pathname = normalizeRequestPath(String(requestUrl ?? '')); + if (!(pathname === '/api' || pathname.startsWith('/api/'))) { + return false; + } + + if (hasHiddenPathSegment(pathname)) { + return false; + } + + if (isIgnoredScannerProbePath(pathname)) { + return false; + } + + return !isIgnoredOpaqueApiProbePath(pathname); +} diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts new file mode 100755 index 0000000..245406d --- /dev/null +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -0,0 +1,48 @@ +import type { FastifyInstance } from 'fastify'; +import { getAppConfig, upsertAppConfig } from '../services/app-config-service.js'; + +export async function registerAppConfigRoutes(app: FastifyInstance) { + app.get('/api/app-config', async () => { + const config = await getAppConfig(); + + return { + ok: true, + config: config ?? {}, + }; + }); + + app.put('/api/app-config', async (request, reply) => { + try { + let payload: unknown = request.body ?? {}; + + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + payload = {}; + } + } + + if (!payload || typeof payload !== 'object' || !('config' in payload)) { + throw new Error('์ €์žฅํ•  ์„ค์ • ๊ฐ’์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + const config = (payload as { config: unknown }).config; + + if (!config || typeof config !== 'object') { + throw new Error('์„ค์ • ๊ฐ’ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + + const savedConfig = await upsertAppConfig(config as Record); + + return { + ok: true, + config: savedConfig, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '์•ฑ ์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); +} diff --git a/etc/servers/work-server/src/routes/board.ts b/etc/servers/work-server/src/routes/board.ts new file mode 100755 index 0000000..d21e23e --- /dev/null +++ b/etc/servers/work-server/src/routes/board.ts @@ -0,0 +1,172 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; +import { + BoardPostAutomationLockedError, + boardPostPayloadSchema, + createBoardPost, + deleteBoardPost, + ensureBoardPostsTable, + getBoardPost, + listBoardPosts, + receiveBoardPostAutomation, + updateBoardPost, +} from '../services/board-service.js'; + +export async function registerBoardRoutes(app: FastifyInstance) { + function isLoopbackAddress(value: string | null | undefined) { + const normalizedValue = String(value ?? '').trim(); + return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1'; + } + + function hasBoardAutomationAccess(request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) { + if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) { + return true; + } + + return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress); + } + + function requireBoardAutomationAccess( + request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }, + reply: Parameters[1] extends (request: any, reply: infer T) => any ? T : any, + ) { + if (hasBoardAutomationAccess(request)) { + return true; + } + + void reply.code(403).send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๋งŒ ์ž๋™ํ™” ์ ‘์ˆ˜๋ฅผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + return false; + } + + async function respondWithBoardSetup() { + await ensureBoardPostsTable(); + + return { + ok: true, + table: 'board_posts', + }; + } + + async function respondWithBoardPosts() { + const items = await listBoardPosts(); + + return { + ok: true, + items, + }; + } + + app.get('/api/board/setup', async () => respondWithBoardSetup()); + app.post('/api/board/setup', async () => { + return respondWithBoardSetup(); + }); + + app.get('/api/board/posts', async () => respondWithBoardPosts()); + app.get('/api/board/items', async () => respondWithBoardPosts()); + + app.get('/api/board/posts/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getBoardPost(id); + + if (!item) { + return reply.code(404).send({ + message: '๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item, + }; + }); + + app.post('/api/board/posts', async (request) => { + const item = await createBoardPost(boardPostPayloadSchema.parse(request.body ?? {})); + + return { + ok: true, + item, + }; + }); + + app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => { + if (!requireBoardAutomationAccess(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const result = await receiveBoardPostAutomation(id); + + if (!result) { + return reply.code(404).send({ + message: '์ž๋™ํ™” ์ ‘์ˆ˜ํ•  ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item: result.item, + planItemId: result.planItemId, + alreadyReceived: result.alreadyReceived, + }; + }); + + app.patch('/api/board/posts/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + let item; + + try { + item = await updateBoardPost(id, boardPostPayloadSchema.parse(request.body ?? {})); + } catch (error) { + if (error instanceof BoardPostAutomationLockedError) { + return reply.code(409).send({ + message: error.message, + }); + } + + throw error; + } + + if (!item) { + return reply.code(404).send({ + message: '์ˆ˜์ •ํ•  ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item, + }; + }); + + app.delete('/api/board/posts/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + let deleted; + + try { + deleted = await deleteBoardPost(id); + } catch (error) { + if (error instanceof BoardPostAutomationLockedError) { + return reply.code(409).send({ + message: error.message, + }); + } + + throw error; + } + + if (!deleted) { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ๊ฒŒ์‹œ๊ธ€์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + id, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts new file mode 100755 index 0000000..51bdf3f --- /dev/null +++ b/etc/servers/work-server/src/routes/chat.ts @@ -0,0 +1,500 @@ +import { randomUUID } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { access, mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { z } from 'zod'; +import { env } from '../config/env.js'; +import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; +import { getChatRuntimeController } from '../services/chat-service.js'; +import { + createChatConversation, + deleteUnansweredChatConversationRequest, + deleteChatConversation, + ensureChatConversationTables, + getChatConversation, + listChatConversationActivityLogs, + listChatConversationMessages, + listChatConversationRequests, + listChatConversations, + markChatConversationResponsesRead, + updateChatConversationContext, +} from '../services/chat-room-service.js'; +import { chatRuntimeService } from '../services/chat-runtime-service.js'; + +const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024; +const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024; +const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/'; +const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; + +function resolveStaticContentType(filePath: string) { + const extension = path.extname(filePath).toLowerCase(); + + switch (extension) { + case '.ts': + case '.tsx': + case '.js': + case '.jsx': + case '.mjs': + case '.cjs': + case '.json': + case '.css': + case '.html': + case '.md': + case '.txt': + case '.diff': + return 'text/plain; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.ico': + return 'image/x-icon'; + case '.pdf': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } +} + +function buildChatResourcePublicUrl(relativePath: string) { + const normalizedRelativePath = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); + return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${normalizedRelativePath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/')}`; +} + +function normalizeChatResourceWildcard(wildcard: string) { + const cleaned = wildcard.trim().replace(/^\/+/, '').replace(/^public\//, ''); + + if (!cleaned) { + return ''; + } + + if (cleaned.startsWith('.codex_chat/')) { + return cleaned; + } + + return path.posix.join('.codex_chat', cleaned); +} + +async function serveChatPublicResource( + repoPath: string, + wildcard: string, + reply: FastifyReply, +) { + const requestedRelativePath = normalizeChatResourceWildcard(wildcard); + + if (!requestedRelativePath) { + return reply.code(404).send({ + message: '์ฑ„ํŒ… ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const publicRoot = path.join(repoPath, 'public'); + const absolutePath = path.resolve(publicRoot, requestedRelativePath); + + if (!absolutePath.startsWith(`${publicRoot}${path.sep}`)) { + return reply.code(403).send({ + message: 'ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค.', + }); + } + + try { + await access(absolutePath); + const fileStat = await stat(absolutePath); + + if (!fileStat.isFile()) { + return reply.code(404).send({ + message: '์ฑ„ํŒ… ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + } catch { + return reply.code(404).send({ + message: '์ฑ„ํŒ… ๋ฆฌ์†Œ์Šค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + reply.header('Cache-Control', 'no-store'); + reply.type(resolveStaticContentType(absolutePath)); + return reply.send(createReadStream(absolutePath)); +} + +function sanitizeChatAttachmentFileName(fileName: string) { + const normalized = fileName.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' '); + const compact = normalized || 'attachment'; + return compact.length > 120 ? compact.slice(-120) : compact; +} + +function resolveChatAttachmentRepoPath() { + return path.resolve(env.PLAN_MAIN_PROJECT_REPO_PATH ?? env.PLAN_GIT_REPO_PATH); +} + +function getClientIdHeader(request: { headers: Record }) { + const raw = request.headers['x-client-id']; + return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim(); +} + +function canViewAllConversations(request: { headers: Record }) { + return hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined); +} + +export async function registerChatRoutes(app: FastifyInstance) { + app.get(`${CHAT_PUBLIC_ROUTE_PREFIX}*`, async (request, reply) => { + const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); + return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); + }); + + app.get(`${CHAT_API_RESOURCE_ROUTE_PREFIX}/*`, async (request, reply) => { + const wildcard = String((request.params as { '*': string | undefined })['*'] ?? '').trim(); + return serveChatPublicResource(resolveChatAttachmentRepoPath(), wildcard, reply); + }); + + app.get('/api/chat/setup', async () => { + await ensureChatConversationTables(); + + return { + ok: true, + tables: ['chat_conversations', 'chat_conversation_messages', 'chat_conversation_requests'], + }; + }); + + app.get('/api/chat/conversations', async (request) => { + const query = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), + }).parse(request.query ?? {}); + + const viewerClientId = getClientIdHeader(request); + const clientId = canViewAllConversations(request) ? null : viewerClientId; + const items = await listChatConversations(clientId, query.limit ?? 50, viewerClientId || null); + + return { + ok: true, + items, + }; + }); + + app.get('/api/chat/runtime', async () => { + return { + ok: true, + item: chatRuntimeService.getSnapshot(), + }; + }); + + app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { + const payload = z.object({ + sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/), + fileName: z.string().trim().min(1).max(255), + mimeType: z.string().trim().max(200).optional(), + contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), + }).parse(request.body ?? {}); + + const buffer = Buffer.from(payload.contentBase64, 'base64'); + + if (buffer.byteLength === 0) { + return reply.code(400).send({ + message: '์—…๋กœ๋“œํ•  ํŒŒ์ผ ๋‚ด์šฉ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + if (buffer.byteLength > CHAT_ATTACHMENT_FILE_SIZE_LIMIT) { + return reply.code(413).send({ + message: '์ฒจ๋ถ€ ํŒŒ์ผ์€ 10MB ์ดํ•˜๋งŒ ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + const safeFileName = sanitizeChatAttachmentFileName(payload.fileName); + const fileToken = `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; + const relativePath = path.posix.join( + 'public', + '.codex_chat', + payload.sessionId, + 'resource', + 'uploads', + `${fileToken}-${safeFileName}`, + ); + const absolutePath = path.join(resolveChatAttachmentRepoPath(), ...relativePath.split('/')); + + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, buffer); + + return { + ok: true, + item: { + id: randomUUID(), + name: payload.fileName, + path: relativePath, + publicUrl: buildChatResourcePublicUrl(relativePath), + size: buffer.byteLength, + mimeType: payload.mimeType?.trim() || 'application/octet-stream', + }, + }; + }); + + app.get('/api/chat/runtime/jobs/:requestId', async (request, reply) => { + const params = z.object({ + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const controller = getChatRuntimeController(); + + if (!controller) { + return reply.code(503).send({ + message: '์ฑ„ํŒ… ๋Ÿฐํƒ€์ž„ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item: controller.getJobDetail(params.requestId), + }; + }); + + app.post('/api/chat/runtime/jobs/:requestId/cancel', async (request, reply) => { + const params = z.object({ + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const controller = getChatRuntimeController(); + + if (!controller) { + return reply.code(503).send({ + message: '์ฑ„ํŒ… ๋Ÿฐํƒ€์ž„ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + }); + } + + const cancelled = await controller.cancelJob(params.requestId); + + if (!cancelled) { + return reply.code(404).send({ + message: '์ทจ์†Œํ•  ์‹คํ–‰ ์ค‘ ์š”์ฒญ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + cancelled: true, + }; + }); + + app.post('/api/chat/runtime/jobs/:requestId/remove', async (request, reply) => { + const params = z.object({ + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const controller = getChatRuntimeController(); + + if (!controller) { + return reply.code(503).send({ + message: '์ฑ„ํŒ… ๋Ÿฐํƒ€์ž„ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + }); + } + + const removed = await controller.removeQueuedJob(params.requestId); + + if (!removed) { + return reply.code(404).send({ + message: '์ œ๊ฑฐํ•  ๋Œ€๊ธฐ ์š”์ฒญ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + removed: true, + }; + }); + + app.post('/api/chat/conversations', async (request) => { + const payload = z.object({ + sessionId: z.string().trim().min(1).max(120), + title: z.string().trim().max(200).optional(), + contextLabel: z.string().trim().max(200).optional(), + contextDescription: z.string().trim().max(2000).optional(), + notifyOffline: z.boolean().optional(), + }).parse(request.body ?? {}); + + const clientId = getClientIdHeader(request); + const item = await createChatConversation({ + sessionId: payload.sessionId, + clientId: clientId || null, + title: payload.title ?? '์ƒˆ ๋Œ€ํ™”', + contextLabel: payload.contextLabel ?? null, + contextDescription: payload.contextDescription ?? null, + notifyOffline: payload.notifyOffline ?? true, + }); + + return { + ok: true, + item, + }; + }); + + app.get('/api/chat/conversations/:sessionId', async (request, reply) => { + const params = z.object({ + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const query = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), + beforeMessageId: z.coerce.number().int().positive().optional(), + }).parse(request.query ?? {}); + + const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); + const item = await getChatConversation(params.sessionId, clientId || null); + + if (!item) { + return reply.code(404).send({ + message: '์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const messageLimit = query.limit ?? 500; + const messages = await listChatConversationMessages(params.sessionId, { + limit: messageLimit, + beforeMessageId: query.beforeMessageId ?? null, + }); + const requests = await listChatConversationRequests(params.sessionId, 500); + const activityLogs = await listChatConversationActivityLogs(params.sessionId, 500); + const oldestLoadedMessageId = messages[0]?.id ?? null; + const hasOlderMessages = + oldestLoadedMessageId != null + ? (await listChatConversationMessages(params.sessionId, { + limit: 1, + beforeMessageId: oldestLoadedMessageId, + })).length > 0 + : false; + + return { + ok: true, + item, + messages, + requests, + activityLogs, + oldestLoadedMessageId, + hasOlderMessages, + }; + }); + + app.post('/api/chat/conversations/:sessionId/read', async (request, reply) => { + const params = z.object({ + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + + const clientId = getClientIdHeader(request); + + if (!clientId) { + return reply.code(400).send({ + message: '์ฝ์Œ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•œ clientId๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', + }); + } + + const result = await markChatConversationResponsesRead(params.sessionId, clientId); + + if (!result) { + return reply.code(404).send({ + message: '์ฝ์Œ ์ฒ˜๋ฆฌํ•  ์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + ...result, + }; + }); + + app.delete('/api/chat/conversations/:sessionId/requests/:requestId', async (request, reply) => { + const params = z.object({ + sessionId: z.string().trim().min(1).max(120), + requestId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + + const result = await deleteUnansweredChatConversationRequest(params.sessionId, params.requestId); + + if (!result.deleted) { + if (result.reason === 'not_found') { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ์š”์ฒญ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + if (result.reason === 'answered') { + return reply.code(409).send({ + message: '์ด๋ฏธ ๋‹ต๋ณ€์ด ์—ฐ๊ฒฐ๋œ ์š”์ฒญ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return reply.code(409).send({ + message: 'ํ˜„์žฌ ์ฒ˜๋ฆฌ ์ค‘์ธ ์š”์ฒญ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + deleted: true, + sessionId: params.sessionId, + requestId: params.requestId, + }; + }); + + app.patch('/api/chat/conversations/:sessionId', async (request, reply) => { + const params = z.object({ + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + const payload = z.object({ + title: z.string().trim().min(1).max(200).optional(), + contextLabel: z.string().trim().max(200).optional().nullable(), + contextDescription: z.string().trim().max(2000).optional().nullable(), + notifyOffline: z.boolean().optional(), + }).parse(request.body ?? {}); + + const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); + const current = await getChatConversation(params.sessionId, clientId || null); + + if (!current) { + return reply.code(404).send({ + message: '์ˆ˜์ •ํ•  ์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const item = await updateChatConversationContext(params.sessionId, { + title: payload.title ?? current.title, + clientId: current.clientId, + contextLabel: payload.contextLabel ?? current.contextLabel, + contextDescription: payload.contextDescription ?? current.contextDescription, + notifyOffline: payload.notifyOffline ?? current.notifyOffline, + }); + + return { + ok: true, + item, + }; + }); + + app.delete('/api/chat/conversations/:sessionId', async (request, reply) => { + const params = z.object({ + sessionId: z.string().trim().min(1).max(120), + }).parse(request.params ?? {}); + + const clientId = canViewAllConversations(request) ? null : getClientIdHeader(request); + const current = await getChatConversation(params.sessionId, clientId || null); + + if (!current) { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ์ฑ„ํŒ…๋ฐฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const deleted = await deleteChatConversation(params.sessionId); + + return { + ok: true, + deleted, + sessionId: params.sessionId, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/compatibility.test.ts b/etc/servers/work-server/src/routes/compatibility.test.ts new file mode 100755 index 0000000..2a25b96 --- /dev/null +++ b/etc/servers/work-server/src/routes/compatibility.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Fastify from 'fastify'; +import { registerJsonBodyParser } from '../json-body.js'; +import { registerBoardRoutes } from './board.js'; +import { registerNotificationRoutes } from './notification.js'; + +function createRouteRecorder() { + const routes: Array<{ method: string; path: string }> = []; + const record = (method: string) => (path: string) => { + routes.push({ method, path }); + return undefined; + }; + const app = { + get: record('GET'), + post: record('POST'), + put: record('PUT'), + patch: record('PATCH'), + delete: record('DELETE'), + }; + + return { + app: app as any, + routes, + }; +} + +test('registerJsonBodyParser treats empty json body as an empty object', async () => { + const app = Fastify(); + registerJsonBodyParser(app); + app.post('/json', async (request) => ({ + body: request.body, + })); + + const response = await app.inject({ + method: 'POST', + url: '/json', + headers: { + 'content-type': 'application/json', + }, + payload: '', + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), { + body: {}, + }); + await app.close(); +}); + +test('registerJsonBodyParser still rejects malformed json', async () => { + const app = Fastify(); + registerJsonBodyParser(app); + app.post('/json', async (request) => ({ + body: request.body, + })); + + const response = await app.inject({ + method: 'POST', + url: '/json', + headers: { + 'content-type': 'application/json', + }, + payload: '{', + }); + + assert.equal(response.statusCode, 400); + assert.match(response.body, /valid JSON/i); + await app.close(); +}); + +test('registerBoardRoutes keeps setup and items compatibility routes', async () => { + const { app, routes } = createRouteRecorder(); + await registerBoardRoutes(app); + + assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/setup')); + assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/board/setup')); + assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/posts')); + assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/board/items')); +}); + +test('registerNotificationRoutes exposes notification message routes', async () => { + const { app, routes } = createRouteRecorder(); + await registerNotificationRoutes(app); + + assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages')); + assert.ok(routes.some((route) => route.method === 'GET' && route.path === '/api/notifications/messages/:id')); + assert.ok(routes.some((route) => route.method === 'POST' && route.path === '/api/notifications/messages')); + assert.ok(routes.some((route) => route.method === 'PATCH' && route.path === '/api/notifications/messages/:id')); + assert.ok(routes.some((route) => route.method === 'DELETE' && route.path === '/api/notifications/messages/:id')); +}); diff --git a/etc/servers/work-server/src/routes/crud.ts b/etc/servers/work-server/src/routes/crud.ts new file mode 100755 index 0000000..06f42bc --- /dev/null +++ b/etc/servers/work-server/src/routes/crud.ts @@ -0,0 +1,220 @@ +import type { Knex } from 'knex'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { db } from '../db/client.js'; +import { assertIdentifier } from '../lib/identifier.js'; + +const filterSchema = z.object({ + field: z.string(), + operator: z + .enum(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'like', 'in', 'null', 'notNull']) + .default('eq'), + value: z.any().optional(), +}); + +const orderBySchema = z.object({ + field: z.string(), + direction: z.enum(['asc', 'desc']).default('asc'), +}); + +const selectSchema = z.object({ + columns: z.array(z.string()).optional(), + where: z.array(filterSchema).optional(), + orderBy: z.array(orderBySchema).optional(), + limit: z.number().int().positive().max(500).optional(), + offset: z.number().int().min(0).optional(), +}); + +const insertSchema = z.object({ + data: z.record(z.string(), z.any()).or(z.array(z.record(z.string(), z.any()))), +}); + +const updateSchema = z.object({ + data: z.record(z.string(), z.any()), + where: z.array(filterSchema).default([]), +}); + +const deleteSchema = z.object({ + where: z.array(filterSchema).default([]), +}); + +const protectedBoardPostAutomationFields = new Set(['automation_plan_item_id', 'automation_received_at']); + +function applyFilters(query: Knex.QueryBuilder, filters: z.infer[] = []) { + filters.forEach((filter) => { + const field = assertIdentifier(filter.field, 'field'); + + switch (filter.operator) { + case 'eq': + query.where(field, filter.value); + break; + case 'ne': + query.whereNot(field, filter.value); + break; + case 'gt': + query.where(field, '>', filter.value); + break; + case 'gte': + query.where(field, '>=', filter.value); + break; + case 'lt': + query.where(field, '<', filter.value); + break; + case 'lte': + query.where(field, '<=', filter.value); + break; + case 'like': + query.where(field, 'like', filter.value); + break; + case 'in': + query.whereIn(field, Array.isArray(filter.value) ? filter.value : [filter.value]); + break; + case 'null': + query.whereNull(field); + break; + case 'notNull': + query.whereNotNull(field); + break; + default: + break; + } + }); +} + +export async function registerCrudRoutes(app: FastifyInstance) { + function getRequestTraceContext(request: FastifyRequest) { + return { + ip: request.ip, + remoteAddress: request.raw.socket.remoteAddress, + host: request.headers.host, + origin: request.headers.origin, + referer: request.headers.referer, + userAgent: request.headers['user-agent'], + clientId: request.headers['x-client-id'], + }; + } + + function summarizeCrudUpdatePayload(table: string, payload: z.infer) { + if (table !== 'board_posts') { + return { + dataKeys: Object.keys(payload.data), + where: payload.where, + }; + } + + return { + dataKeys: Object.keys(payload.data), + where: payload.where, + automationPlanItemId: payload.data.automation_plan_item_id ?? null, + automationReceivedAt: payload.data.automation_received_at ?? null, + title: typeof payload.data.title === 'string' ? payload.data.title : undefined, + contentLength: typeof payload.data.content === 'string' ? payload.data.content.length : undefined, + }; + } + + app.post('/api/crud/:table/select', async (request) => { + const table = assertIdentifier((request.params as { table: string }).table, 'table name'); + const payload = selectSchema.parse(request.body ?? {}); + const columns = payload.columns?.map((column) => assertIdentifier(column, 'column')) ?? ['*']; + + const query = db(table).select(columns); + applyFilters(query, payload.where); + + payload.orderBy?.forEach((order) => { + query.orderBy(assertIdentifier(order.field, 'order field'), order.direction); + }); + + if (payload.limit) { + query.limit(payload.limit); + } + + if (payload.offset) { + query.offset(payload.offset); + } + + const rows = await query; + + return { + ok: true, + table, + count: rows.length, + rows, + }; + }); + + app.post('/api/crud/:table/insert', async (request) => { + const table = assertIdentifier((request.params as { table: string }).table, 'table name'); + const payload = insertSchema.parse(request.body); + const inserted = await db(table).insert(payload.data).returning('*'); + + return { + ok: true, + table, + rows: inserted, + }; + }); + + app.patch('/api/crud/:table/update', async (request, reply) => { + const table = assertIdentifier((request.params as { table: string }).table, 'table name'); + const payload = updateSchema.parse(request.body); + const query = db(table); + + applyFilters(query, payload.where); + + if (table === 'board_posts') { + request.log.warn( + { + table, + payload: summarizeCrudUpdatePayload(table, payload), + trace: getRequestTraceContext(request), + }, + 'Board post CRUD update requested', + ); + + const protectedFields = Object.keys(payload.data).filter((field) => protectedBoardPostAutomationFields.has(field)); + + if (protectedFields.length) { + request.log.warn( + { + table, + protectedFields, + payload: summarizeCrudUpdatePayload(table, payload), + trace: getRequestTraceContext(request), + }, + 'Board post CRUD update blocked from changing automation link fields', + ); + + return reply.code(409).send({ + message: '์ž๋™ํ™” ์ ‘์ˆ˜ ์—ฐ๊ฒฐ ํ•„๋“œ๋Š” ์ผ๋ฐ˜ CRUD ์ˆ˜์ •์œผ๋กœ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + protectedFields, + }); + } + } + + const rows = await query.update(payload.data).returning('*'); + + return { + ok: true, + table, + count: rows.length, + rows, + }; + }); + + app.delete('/api/crud/:table/delete', async (request) => { + const table = assertIdentifier((request.params as { table: string }).table, 'table name'); + const payload = deleteSchema.parse(request.body ?? {}); + const query = db(table); + + applyFilters(query, payload.where); + + const rows = await query.delete().returning('*'); + + return { + ok: true, + table, + count: rows.length, + rows, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/ddl.ts b/etc/servers/work-server/src/routes/ddl.ts new file mode 100755 index 0000000..9aceb79 --- /dev/null +++ b/etc/servers/work-server/src/routes/ddl.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { db } from '../db/client.js'; +import { assertIdentifier } from '../lib/identifier.js'; + +const columnSchema = z.object({ + name: z.string(), + type: z.string(), + nullable: z.boolean().optional(), + primary: z.boolean().optional(), + unique: z.boolean().optional(), + defaultTo: z.any().optional(), +}); + +const createTableSchema = z.object({ + tableName: z.string(), + columns: z.array(columnSchema).min(1), +}); + +const dropTableSchema = z.object({ + tableName: z.string(), +}); + +const addColumnSchema = z.object({ + tableName: z.string(), + column: columnSchema, +}); + +const dropColumnSchema = z.object({ + tableName: z.string(), + columnName: z.string(), +}); + +const rawDdlSchema = z.object({ + sql: z.string().min(1), +}); + +function applyColumn(tableBuilder: any, column: z.infer) { + const name = assertIdentifier(column.name, 'column name'); + const definition = tableBuilder.specificType(name, column.type); + + if (column.nullable === false) { + definition.notNullable(); + } + + if (column.nullable === true) { + definition.nullable(); + } + + if (column.primary) { + definition.primary(); + } + + if (column.unique) { + definition.unique(); + } + + if (column.defaultTo !== undefined) { + definition.defaultTo(column.defaultTo); + } +} + +export async function registerDdlRoutes(app: FastifyInstance) { + app.post('/api/ddl/create-table', async (request) => { + const payload = createTableSchema.parse(request.body); + const tableName = assertIdentifier(payload.tableName, 'table name'); + + await db.schema.createTable(tableName, (table) => { + payload.columns.forEach((column) => { + applyColumn(table, column); + }); + }); + + return { ok: true, action: 'create-table', tableName }; + }); + + app.post('/api/ddl/drop-table', async (request) => { + const payload = dropTableSchema.parse(request.body); + const tableName = assertIdentifier(payload.tableName, 'table name'); + + await db.schema.dropTableIfExists(tableName); + + return { ok: true, action: 'drop-table', tableName }; + }); + + app.post('/api/ddl/add-column', async (request) => { + const payload = addColumnSchema.parse(request.body); + const tableName = assertIdentifier(payload.tableName, 'table name'); + + await db.schema.alterTable(tableName, (table) => { + applyColumn(table, payload.column); + }); + + return { ok: true, action: 'add-column', tableName, column: payload.column.name }; + }); + + app.post('/api/ddl/drop-column', async (request) => { + const payload = dropColumnSchema.parse(request.body); + const tableName = assertIdentifier(payload.tableName, 'table name'); + const columnName = assertIdentifier(payload.columnName, 'column name'); + + await db.schema.alterTable(tableName, (table) => { + table.dropColumn(columnName); + }); + + return { ok: true, action: 'drop-column', tableName, columnName }; + }); + + app.post('/api/ddl/raw', async (request) => { + const payload = rawDdlSchema.parse(request.body); + const result = await db.raw(payload.sql); + + return { + ok: true, + action: 'raw', + result, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/error-log.ts b/etc/servers/work-server/src/routes/error-log.ts new file mode 100755 index 0000000..175b2c6 --- /dev/null +++ b/etc/servers/work-server/src/routes/error-log.ts @@ -0,0 +1,49 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + createErrorLog, + createErrorLogSchema, + hasErrorLogViewAccessToken, + listErrorLogs, + setupErrorLogTable, +} from '../services/error-log-service.js'; + +const errorLogListQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(200).optional(), +}); + +export async function registerErrorLogRoutes(app: FastifyInstance) { + app.post('/api/error-logs/setup', async () => { + return setupErrorLogTable(); + }); + + app.get('/api/error-logs', async (request, reply) => { + const accessToken = request.headers['x-access-token']; + + if (!hasErrorLogViewAccessToken(accessToken)) { + reply.status(403); + return { + ok: false, + message: '์—๋Ÿฌ ๋กœ๊ทธ ์กฐํšŒ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }; + } + + const query = errorLogListQuerySchema.parse(request.query ?? {}); + const items = await listErrorLogs(query.limit ?? 50); + + return { + ok: true, + items, + }; + }); + + app.post('/api/error-logs/report', async (request) => { + const payload = createErrorLogSchema.parse(request.body); + const item = await createErrorLog(payload); + + return { + ok: true, + item, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/health.ts b/etc/servers/work-server/src/routes/health.ts new file mode 100755 index 0000000..46b01d7 --- /dev/null +++ b/etc/servers/work-server/src/routes/health.ts @@ -0,0 +1,13 @@ +import type { FastifyInstance } from 'fastify'; + +export async function registerHealthRoutes(app: FastifyInstance) { + const respondHealth = async () => ({ + ok: true, + service: 'work-server', + timestamp: new Date().toISOString(), + }); + + app.get('/', respondHealth); + app.get('/api', respondHealth); + app.get('/health', respondHealth); +} diff --git a/etc/servers/work-server/src/routes/notification.ts b/etc/servers/work-server/src/routes/notification.ts new file mode 100755 index 0000000..f5fc304 --- /dev/null +++ b/etc/servers/work-server/src/routes/notification.ts @@ -0,0 +1,207 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + listIosNotificationTokens, + getAutomationNotificationPreference, + getWebPushConfig, + registerIosNotificationToken, + registerAutomationNotificationPreferenceSchema, + registerIosTokenSchema, + registerWebPushSubscription, + registerWebPushSubscriptionSchema, + sendNotifications, + sendIosNotificationSchema, + setupNotificationTables, + upsertAutomationNotificationPreference, + unregisterIosNotificationToken, + unregisterIosTokenSchema, + unregisterWebPushSubscription, + unregisterWebPushSubscriptionSchema, +} from '../services/notification-service.js'; +import { + createNotificationMessage, + deleteNotificationMessage, + getNotificationMessage, + listNotificationMessages, + notificationMessageListQuerySchema, + notificationMessagePayloadSchema, + notificationMessageReadPayloadSchema, + updateNotificationMessageReadState, +} from '../services/notification-message-service.js'; + +const automationNotificationPreferenceQuerySchema = z.object({ + targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(), + targetId: z.string().trim().min(1).max(1000).optional(), +}); + +type AutomationNotificationPreferenceTargetKind = NonNullable< + z.infer['targetKind'] +>; + +function getClientIdHeader(request: { headers: Record }) { + const rawClientId = request.headers['x-client-id']; + const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId; + return clientId?.trim() ?? ''; +} + +export async function registerNotificationRoutes(app: FastifyInstance) { + app.post('/api/notifications/setup', async () => setupNotificationTables()); + + app.get('/api/notifications/tokens', async () => ({ + items: await listIosNotificationTokens(), + })); + + app.get('/api/notifications/webpush/config', async () => getWebPushConfig()); + + app.get('/api/notifications/messages', async (request) => { + const query = notificationMessageListQuerySchema.parse(request.query ?? {}); + return { + ok: true, + ...(await listNotificationMessages(query)), + }; + }); + + app.get('/api/notifications/messages/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getNotificationMessage(id); + + if (!item) { + return reply.code(404).send({ + message: '์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item, + }; + }); + + app.post('/api/notifications/messages', async (request) => { + const item = await createNotificationMessage(notificationMessagePayloadSchema.parse(request.body ?? {})); + + return { + ok: true, + item, + }; + }); + + app.patch('/api/notifications/messages/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await updateNotificationMessageReadState(id, notificationMessageReadPayloadSchema.parse(request.body ?? {})); + + if (!item) { + return reply.code(404).send({ + message: '์ƒํƒœ๋ฅผ ๋ณ€๊ฒฝํ•  ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item, + }; + }); + + app.delete('/api/notifications/messages/:id', async (request, reply) => { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const deleted = await deleteNotificationMessage(id); + + if (!deleted) { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + deleted: true, + }; + }); + + app.get('/api/notifications/preferences/automation', async (request) => { + const query = automationNotificationPreferenceQuerySchema.parse(request.query ?? {}); + const targetId = query.targetId || getClientIdHeader(request); + const targetKind = query.targetId ? query.targetKind ?? 'client' : 'client'; + + return { + ok: true, + automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind), + }; + }); + + app.put('/api/notifications/preferences/automation', async (request, reply) => { + try { + const payload = registerAutomationNotificationPreferenceSchema.parse(request.body ?? {}); + const targetId = payload.targetId || getClientIdHeader(request); + + if (!targetId) { + throw new Error('์•Œ๋ฆผ ์„ค์ •์„ ์ €์žฅํ•  ํด๋ผ์ด์–ธํŠธ ID๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + return upsertAutomationNotificationPreference({ + ...payload, + targetId, + }); + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '์•Œ๋ฆผ ์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.put('/api/notifications/tokens/ios', async (request) => { + const payload = registerIosTokenSchema.parse(request.body ?? {}); + return registerIosNotificationToken(payload); + }); + + app.delete('/api/notifications/tokens/ios', async (request) => { + const payload = unregisterIosTokenSchema.parse(request.body ?? {}); + return unregisterIosNotificationToken(payload.token); + }); + + app.put('/api/notifications/subscriptions/web', async (request) => { + const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {}); + return registerWebPushSubscription(payload); + }); + + app.delete('/api/notifications/subscriptions/web', async (request) => { + const payload = unregisterWebPushSubscriptionSchema.parse(request.body ?? {}); + return unregisterWebPushSubscription(payload.endpoint); + }); + + app.post('/api/notifications/send', async (request) => { + const payload = sendIosNotificationSchema.parse(request.body ?? {}); + return sendNotifications(payload); + }); + + app.post('/api/notifications/send-test', async (request) => { + const payload = sendIosNotificationSchema.parse(request.body ?? {}); + return sendNotifications(payload); + }); +} +async function getAutomationNotificationPreferenceWithFallback( + targetId: string, + targetKind: AutomationNotificationPreferenceTargetKind, +) { + const automation = await getAutomationNotificationPreference(targetId, targetKind); + + if (automation || targetKind !== 'ios-token-client') { + return automation; + } + + const [token, clientId] = targetId.split('::client::'); + + if (token?.trim()) { + const tokenAutomation = await getAutomationNotificationPreference(token.trim(), 'ios-token'); + + if (tokenAutomation) { + return tokenAutomation; + } + } + + if (clientId?.trim()) { + return getAutomationNotificationPreference(clientId.trim(), 'client'); + } + + return null; +} diff --git a/etc/servers/work-server/src/routes/plan.ts b/etc/servers/work-server/src/routes/plan.ts new file mode 100755 index 0000000..53b09bf --- /dev/null +++ b/etc/servers/work-server/src/routes/plan.ts @@ -0,0 +1,981 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; +import { notifyPlanEvent } from '../services/plan-notification-service.js'; +import { shouldNotifyPlanRestart } from '../services/plan-notification-policy.js'; +import { + PLAN_ACTION_TABLE, + PLAN_ISSUE_TABLE, + PLAN_RELEASE_REVIEW_TABLE, + PLAN_SOURCE_WORK_TABLE, + PLAN_TABLE, + appendLatestIssueAction, + cancelPlanRelease, + createPlanItem, + createPlanActionHistory, + createPlanSourceWorkHistory, + createPlanSchema, + deletePlanItem, + ensurePlanTable, + getPlanSourceWorkHistory, + getBoardPostLinkedToPlanItem, + getPlanItemById, + formatPlanNotificationLabel, + issueActionSchema, + listPlanActionHistories, + listPlanIssueHistories, + listPlanItems, + listPlanReleaseReviewBoardItems, + listPlanSourceWorkHistories, + listPlanQuerySchema, + mapPlanActionRow, + mapPlanIssueRow, + mapPlanSourceWorkRow, + markPlanAsStarted, + planStatuses, + markPlanAsCompleted, + markPlanAsDevelopmentComplete, + queuePlanRetryFromFailure, + queuePlanRetryFromIssueAction, + requestPlanMainMerge, + resumePlanDevelopmentFromRelease, + retryPlanBranch, + retryPlanWork, + retryPlanMerge, + setupSchema, + updatePlanReleaseReviewSchema, + upsertPlanReleaseReview, + updatePlanItem, + updatePlanItemJangsingProcessingRequired, + updatePlanJangsingProcessingSchema, + updatePlanSchema, +} from '../services/plan-service.js'; +import { db } from '../db/client.js'; +import { getEnv } from '../config/env.js'; +import { recreateReleaseBranchFromMain } from '../services/git-service.js'; +import { registerErrorLogBoardPosts } from '../services/error-log-plan-registration-service.js'; +import { + PLAN_SCHEDULED_TASK_TABLE, + createPlanScheduledTask, + createPlanScheduledTaskSchema, + deletePlanScheduledTask, + ensurePlanScheduledTaskTable, + getPlanScheduledTaskById, + listPlanScheduledTasks, + mapPlanScheduledTaskRow, + registerPlanScheduledTaskNow, + updatePlanScheduledTask, + updatePlanScheduledTaskSchema, +} from '../services/plan-schedule-service.js'; +import { getVisitorClientByClientId } from '../services/visitor-history-service.js'; + +const completeActionSchema = z.object({ + note: z.string().trim().min(1).optional(), +}); + +const actionNoteSchema = z.object({ + actionNote: z.string().trim().min(1), + actionType: z.string().trim().min(1).optional(), +}); + +const createSourceWorkSchema = z.object({ + summary: z.string().trim().min(1), + branchName: z.string().trim().min(1), + commitHash: z.string().trim().min(1).nullable().optional(), + previewUrl: z.string().trim().url().nullable().optional(), + changedFiles: z.array(z.string()).default([]), + commandLog: z.string().nullable().optional(), + diffText: z.string().nullable().optional(), + sourceFiles: z + .array( + z.object({ + path: z.string().trim().min(1), + previousPath: z.string().trim().min(1).nullable().optional(), + status: z.enum(['added', 'modified', 'deleted', 'renamed', 'binary', 'unknown']), + language: z.string().trim().min(1), + content: z.string(), + }), + ) + .default([]), +}); + +export async function registerPlanRoutes(app: FastifyInstance) { + function getRequestTraceContext(request: FastifyRequest) { + return { + ip: request.ip, + remoteAddress: request.raw.socket.remoteAddress, + host: request.headers.host, + origin: request.headers.origin, + referer: request.headers.referer, + userAgent: request.headers['user-agent'], + clientId: request.headers['x-client-id'], + }; + } + + function isLoopbackAddress(value: string | null | undefined) { + const normalizedValue = String(value ?? '').trim(); + return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1'; + } + + function hasPlanAccessToken(accessToken: string | string[] | undefined) { + return hasErrorLogViewAccessToken(accessToken); + } + + function hasPlanAccess(request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) { + if (hasPlanAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) { + return true; + } + + return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress); + } + + function requirePlanAccessToken( + request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }, + reply: Parameters[1] extends (request: any, reply: infer T) => any ? T : any, + ) { + if (hasPlanAccess(request)) { + return true; + } + + void reply.code(403).send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + return false; + } + + async function handleListPlanScheduledTasks(request: FastifyRequest, reply: FastifyReply) { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๋งŒ ์Šค์ผ€์ค„์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + const rows = await listPlanScheduledTasks(); + + return { + items: rows.map(mapPlanScheduledTaskRow), + }; + } + + async function handleCreatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const payload = createPlanScheduledTaskSchema.parse(request.body ?? {}); + const row = await createPlanScheduledTask(payload); + const immediateRegistration = payload.enabled && payload.immediateRunEnabled ? await registerPlanScheduledTaskNow(Number(row.id)) : null; + + return { + ok: true, + item: mapPlanScheduledTaskRow(row), + registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, + registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], + }; + } + + async function handleGetPlanScheduledTask(request: FastifyRequest, reply: FastifyReply) { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๋งŒ ์Šค์ผ€์ค„์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await getPlanScheduledTaskById(id); + + if (!row) { + return reply.code(404).send({ + message: '์Šค์ผ€์ค„์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + item: mapPlanScheduledTaskRow(row), + }; + } + + async function handleUpdatePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = updatePlanScheduledTaskSchema.parse(request.body ?? {}); + const row = await updatePlanScheduledTask(id, payload); + + if (!row) { + return reply.code(404).send({ + message: '์ˆ˜์ •ํ•  ์Šค์ผ€์ค„์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const shouldTriggerImmediateRegistration = + row && + Boolean(row.enabled ?? true) && + Boolean(row.immediate_run_enabled ?? true) && + payload.enabled !== false; + const immediateRegistration = shouldTriggerImmediateRegistration ? await registerPlanScheduledTaskNow(id) : null; + + return { + ok: true, + item: mapPlanScheduledTaskRow(row), + registeredPlan: immediateRegistration?.createdPlan ? await getPlanItemById(Number(immediateRegistration.createdPlan.id)) : null, + registeredBoardPosts: immediateRegistration?.createdBoardPosts ?? [], + }; + } + + app.post('/api/plan/registrations/error-logs', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const payload = z.object({ + rangeStart: z.coerce.date().optional(), + rangeEnd: z.coerce.date().optional(), + maxGroups: z.coerce.number().int().min(1).max(24).optional(), + }).parse(request.body ?? {}); + + const result = await registerErrorLogBoardPosts(payload); + + return { + ok: true, + rangeStart: result.rangeStart.toISOString(), + rangeEnd: result.rangeEnd.toISOString(), + recentLogCount: result.recentLogs.length, + candidateCount: result.candidates.length, + rawCandidateCount: result.rawCandidates.length, + createdBoardPosts: result.createdPosts, + skippedBoardPosts: result.skippedPosts, + }; + }); + + async function handleDeletePlanScheduledTask(request: FastifyRequest, reply: FastifyReply) { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await deletePlanScheduledTask(id); + + if (!row) { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ์Šค์ผ€์ค„์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + id, + }; + } + + app.get('/api/plan/statuses', async () => ({ + items: planStatuses, + })); + + app.post('/api/plan/setup', async (request) => { + const payload = setupSchema.parse(request.body ?? {}); + + if (payload.recreate) { + await db.schema.dropTableIfExists(PLAN_SCHEDULED_TASK_TABLE); + await db.schema.dropTableIfExists(PLAN_ACTION_TABLE); + await db.schema.dropTableIfExists(PLAN_ISSUE_TABLE); + await db.schema.dropTableIfExists(PLAN_SOURCE_WORK_TABLE); + await db.schema.dropTableIfExists(PLAN_TABLE); + } + + await ensurePlanTable(); + await ensurePlanScheduledTaskTable(); + + return { + ok: true, + table: PLAN_TABLE, + scheduleTable: PLAN_SCHEDULED_TASK_TABLE, + releaseReviewTable: PLAN_RELEASE_REVIEW_TABLE, + statuses: planStatuses, + }; + }); + + app.get('/api/plan/release-reviews', async (request) => { + const items = await listPlanReleaseReviewBoardItems({ + maskNote: !hasPlanAccess(request), + }); + + return { + items, + }; + }); + + app.patch('/api/plan/release-reviews/:planItemId', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const planItemId = z.coerce.number().int().positive().parse((request.params as { planItemId: string }).planItemId); + const payload = updatePlanReleaseReviewSchema.parse(request.body ?? {}); + const clientId = String(request.headers['x-client-id'] ?? '').trim(); + const visitor = clientId ? await getVisitorClientByClientId(clientId) : null; + const review = await upsertPlanReleaseReview(planItemId, payload, { + clientId: clientId || null, + nickname: visitor?.nickname ?? null, + }); + + if (!review) { + return reply.code(404).send({ + message: '๊ฒ€์ˆ˜ ๋Œ€์ƒ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item: review, + }; + }); + + app.get('/api/plan/scheduled-tasks', handleListPlanScheduledTasks); + app.get('/api/plan/schedule/tasks', handleListPlanScheduledTasks); + app.get('/api/plan/schedule', handleListPlanScheduledTasks); + app.get('/api/plan/schedules', handleListPlanScheduledTasks); + app.get('/api/plans/scheduled-tasks', handleListPlanScheduledTasks); + app.get('/api/plans/schedule/tasks', handleListPlanScheduledTasks); + app.get('/api/plans/schedule', handleListPlanScheduledTasks); + app.get('/api/plans/schedules', handleListPlanScheduledTasks); + app.get('/api/plan/scheduled-tasks/:id', handleGetPlanScheduledTask); + app.get('/api/plan/schedule/tasks/:id', handleGetPlanScheduledTask); + app.get('/api/plan/schedule/:id', handleGetPlanScheduledTask); + app.get('/api/plan/schedules/:id', handleGetPlanScheduledTask); + app.get('/api/plans/scheduled-tasks/:id', handleGetPlanScheduledTask); + app.get('/api/plans/schedule/tasks/:id', handleGetPlanScheduledTask); + app.get('/api/plans/schedule/:id', handleGetPlanScheduledTask); + app.get('/api/plans/schedules/:id', handleGetPlanScheduledTask); + app.post('/api/plan/scheduled-tasks', handleCreatePlanScheduledTask); + app.post('/api/plan/schedule/tasks', handleCreatePlanScheduledTask); + app.post('/api/plan/schedule', handleCreatePlanScheduledTask); + app.post('/api/plan/schedules', handleCreatePlanScheduledTask); + app.post('/api/plans/scheduled-tasks', handleCreatePlanScheduledTask); + app.post('/api/plans/schedule/tasks', handleCreatePlanScheduledTask); + app.post('/api/plans/schedule', handleCreatePlanScheduledTask); + app.post('/api/plans/schedules', handleCreatePlanScheduledTask); + app.patch('/api/plan/scheduled-tasks/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plan/schedule/tasks/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plan/schedule/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plan/schedules/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plans/scheduled-tasks/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plans/schedule/tasks/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plans/schedule/:id', handleUpdatePlanScheduledTask); + app.patch('/api/plans/schedules/:id', handleUpdatePlanScheduledTask); + app.delete('/api/plan/scheduled-tasks/:id', handleDeletePlanScheduledTask); + app.delete('/api/plan/schedule/tasks/:id', handleDeletePlanScheduledTask); + app.delete('/api/plan/schedule/:id', handleDeletePlanScheduledTask); + app.delete('/api/plan/schedules/:id', handleDeletePlanScheduledTask); + app.delete('/api/plans/scheduled-tasks/:id', handleDeletePlanScheduledTask); + app.delete('/api/plans/schedule/tasks/:id', handleDeletePlanScheduledTask); + app.delete('/api/plans/schedule/:id', handleDeletePlanScheduledTask); + app.delete('/api/plans/schedules/:id', handleDeletePlanScheduledTask); + + app.get('/api/plan/items', async (request, reply) => { + const parsedQuery = listPlanQuerySchema.safeParse(request.query ?? {}); + + if (!parsedQuery.success) { + return reply.code(400).send({ + message: '์œ ํšจํ•˜์ง€ ์•Š์€ status ์ฟผ๋ฆฌ์ž…๋‹ˆ๋‹ค.', + }); + } + + const query = parsedQuery.data; + const items = await listPlanItems(query.status, { + maskNote: !hasPlanAccess(request), + }); + + return { + items, + }; + }); + + app.get('/api/plan/items/:id', async (request, reply) => { + const hasAccess = hasPlanAccess(request); + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await getPlanItemById(id, { + maskNote: !hasAccess, + }); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + item: row, + }; + }); + + app.post('/api/plan/items', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + try { + const payload = createPlanSchema.parse(request.body ?? {}); + const createdRow = await createPlanItem(payload); + const row = await getPlanItemById(Number(createdRow.id)); + + return { + ok: true, + item: row, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '์ž‘์—… ํ•ญ๋ชฉ ๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.patch('/api/plan/items/:id', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + try { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = updatePlanSchema.parse(request.body ?? {}); + const updatedRow = await updatePlanItem(id, payload); + + if (!updatedRow) { + return reply.code(404).send({ + message: '์ˆ˜์ •ํ•  ์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const row = await getPlanItemById(id); + + return { + ok: true, + item: row, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '์ž‘์—… ํ•ญ๋ชฉ ์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.patch('/api/plan/items/:id/jangsing-processing', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + try { + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = updatePlanJangsingProcessingSchema.parse(request.body ?? {}); + const updatedRow = await updatePlanItemJangsingProcessingRequired(id, payload.jangsingProcessingRequired); + + if (!updatedRow) { + return reply.code(404).send({ + message: '์ˆ˜์ •ํ•  ์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item: await getPlanItemById(id), + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ ์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.delete('/api/plan/items/:id', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + request.log.warn( + { + planItemId: id, + trace: getRequestTraceContext(request), + }, + 'Plan item delete requested', + ); + const linkedBoardPost = await getBoardPostLinkedToPlanItem(id); + + if (linkedBoardPost) { + request.log.warn( + { + planItemId: id, + boardPostId: linkedBoardPost.id, + boardPostTitle: linkedBoardPost.title, + trace: getRequestTraceContext(request), + }, + 'Plan item delete blocked because it is linked to a board post', + ); + + return reply.code(409).send({ + message: `์ž๋™ํ™” ์ ‘์ˆ˜๋œ ํ•ญ๋ชฉ์€ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์—ฐ๊ฒฐ ๊ฒŒ์‹œ๊ธ€ #${linkedBoardPost.id}`, + }); + } + + const row = await deletePlanItem(id); + + if (!row) { + return reply.code(404).send({ + message: '์‚ญ์ œํ•  ์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + id, + }; + }); + + app.post('/api/plan/items/:id/actions/complete-development', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await markPlanAsDevelopmentComplete(id); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] release ๋ฐ˜์˜ ๋Œ€๊ธฐ`, + '์ˆ˜๋™ ์ž‘์—…์™„๋ฃŒ๋กœ release ๋ฐ˜์˜ ๋Œ€๊ธฐ ์ƒํƒœ๊ฐ€ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'development-completed', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/complete', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = completeActionSchema.parse(request.body ?? {}); + const row = await markPlanAsCompleted(id, payload.note); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์™„๋ฃŒ ์ฒ˜๋ฆฌ`, + payload.note ?? '์ž‘์—…์ด ์™„๋ฃŒ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-completed', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/start-work', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await markPlanAsStarted(id); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—…์‹œ์ž‘`, + '์ž‘์—…์ด ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'work-started', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/retry-branch', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await retryPlanBranch(id); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—… ์žฌ์‹œ์ž‘`, + '๋ธŒ๋žœ์น˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-restarted', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/retry-work', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await retryPlanWork(id); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—… ์žฌ์‹œ์ž‘`, + '์ž๋™ ์ž‘์—… ์žฌ์ฒ˜๋ฆฌ๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-restarted', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/retry-merge', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const row = await retryPlanMerge(id); + + if (!row) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const planLabel = formatPlanNotificationLabel(String(row.work_id), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—… ์žฌ์‹œ์ž‘`, + row.worker_status === 'main๋ฐ˜์˜๋Œ€๊ธฐ' ? 'main ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.' : 'release ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-restarted', + ); + + return { + ok: true, + item: await getPlanItemById(id), + }; + }); + + app.post('/api/plan/items/:id/actions/cancel-release', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + try { + const env = getEnv(); + const isReleaseMergeFailure = item.status === '์ž‘์—…์™„๋ฃŒ' && item.workerStatus === 'release๋ฐ˜์˜์‹คํŒจ'; + + if (!isReleaseMergeFailure) { + await recreateReleaseBranchFromMain( + { + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + mainBranch: env.PLAN_MAIN_BRANCH, + }, + String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH), + ); + } + + const result = await cancelPlanRelease(id); + + return { + ok: true, + item: result?.item ?? (await getPlanItemById(id)), + message: result?.message, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : 'release ์ž‘์—…์ทจ์†Œ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.post('/api/plan/items/:id/actions/request-main-merge', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const result = await requestPlanMainMerge(id); + + if (!result) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + item: result.item, + message: result.message, + }; + }); + + app.get('/api/plan/items/:id/issues', async (request, reply) => { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '์ƒ์„ธ ์กฐํšŒ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const rows = await listPlanIssueHistories(id); + + return { + items: rows.map(mapPlanIssueRow), + }; + }); + + app.post('/api/plan/items/:id/issues/action', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = issueActionSchema.parse(request.body ?? {}); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + try { + const row = await appendLatestIssueAction(id, payload.actionNote, payload.resolve); + const retryResult = await queuePlanRetryFromIssueAction(id, payload.actionNote, payload.retry); + + if (payload.resolve) { + const planLabel = formatPlanNotificationLabel(String(item.workId), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ด์Šˆ ํ•ด๊ฒฐ ์ฒ˜๋ฆฌ`, + `${row.issue_tag} ์ด์Šˆ๊ฐ€ ํ•ด๊ฒฐ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, + 'issue-resolved', + ); + } + + if (shouldNotifyPlanRestart(retryResult)) { + const planLabel = formatPlanNotificationLabel(String(item.workId), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—… ์žฌ์‹œ์ž‘`, + retryResult?.message ?? '์ž‘์—… ์žฌ์‹œ์ž‘์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-restarted', + ); + } + + return { + ok: true, + item: mapPlanIssueRow(row), + planItem: retryResult?.item ?? (await getPlanItemById(id)), + message: retryResult?.message, + }; + } catch (error) { + return reply.code(409).send({ + message: error instanceof Error ? error.message : '์ด์Šˆ ์กฐ์น˜ ๊ธฐ๋ก ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }); + + app.get('/api/plan/items/:id/actions', async (request, reply) => { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '์ƒ์„ธ ์กฐํšŒ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const rows = await listPlanActionHistories(id); + + return { + items: rows.map(mapPlanActionRow), + }; + }); + + app.get('/api/plan/items/:id/source-works', async (request, reply) => { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '์ƒ์„ธ ์กฐํšŒ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const rows = await listPlanSourceWorkHistories(id); + + return { + items: rows.map(mapPlanSourceWorkRow), + }; + }); + + app.get('/api/plan/items/:id/source-works/:sourceWorkId', async (request, reply) => { + if (!hasPlanAccess(request)) { + return reply.code(403).send({ + message: '์ƒ์„ธ ์กฐํšŒ ๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const params = z.object({ + id: z.coerce.number().int().positive(), + sourceWorkId: z.coerce.number().int().positive(), + }).parse(request.params); + const row = await getPlanSourceWorkHistory(params.id, params.sourceWorkId); + + if (!row) { + return reply.code(404).send({ + message: '์†Œ์Šค ์ž‘์—… ์ด๋ ฅ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + item: mapPlanSourceWorkRow(row), + }; + }); + + app.post('/api/plan/items/:id/source-works', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = createSourceWorkSchema.parse(request.body ?? {}); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const row = await createPlanSourceWorkHistory(id, payload); + + return { + ok: true, + item: mapPlanSourceWorkRow(row), + }; + }); + + app.post('/api/plan/items/:id/actions/note', async (request, reply) => { + if (!requirePlanAccessToken(request, reply)) { + return; + } + + const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); + const payload = actionNoteSchema.parse(request.body ?? {}); + const item = await getPlanItemById(id); + + if (!item) { + return reply.code(404).send({ + message: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + if (!item.startedAt) { + return reply.code(409).send({ + message: '์ž‘์—…์‹œ์ž‘ ์ดํ›„๋ถ€ํ„ฐ ์กฐ์น˜ ์ด๋ ฅ์„ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + } + + const row = await createPlanActionHistory(id, payload.actionType ?? '์ถ”๊ฐ€์กฐ์น˜', payload.actionNote); + const releaseResumeResult = await resumePlanDevelopmentFromRelease(id, payload.actionNote); + const retryResult = releaseResumeResult?.message + ? releaseResumeResult + : await queuePlanRetryFromFailure(id, payload.actionNote); + + if (shouldNotifyPlanRestart(retryResult)) { + const planLabel = formatPlanNotificationLabel(String(item.workId), id); + await notifyPlanEvent( + id, + `[${planLabel}] ์ž‘์—… ์žฌ์‹œ์ž‘`, + retryResult?.message ?? '์ž‘์—… ์žฌ์‹œ์ž‘์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'plan-restarted', + ); + } + + return { + ok: true, + item: mapPlanActionRow(row), + planItem: retryResult?.item ?? (await getPlanItemById(id)), + message: retryResult?.message, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/schema.ts b/etc/servers/work-server/src/routes/schema.ts new file mode 100755 index 0000000..c8c89cc --- /dev/null +++ b/etc/servers/work-server/src/routes/schema.ts @@ -0,0 +1,17 @@ +import type { FastifyInstance } from 'fastify'; +import { db } from '../db/client.js'; + +export async function registerSchemaRoutes(app: FastifyInstance) { + app.get('/api/schema/tables', async () => { + const tables = await db('information_schema.tables') + .select('table_name', 'table_schema') + .where('table_type', 'BASE TABLE') + .whereNotIn('table_schema', ['pg_catalog', 'information_schema']) + .orderBy('table_schema') + .orderBy('table_name'); + + return { + items: tables, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/server-command.ts b/etc/servers/work-server/src/routes/server-command.ts new file mode 100755 index 0000000..a207998 --- /dev/null +++ b/etc/servers/work-server/src/routes/server-command.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { env } from '../config/env.js'; +import { listServerCommands, restartServerCommand, serverCommandKeys } from '../services/server-command-service.js'; + +const serverCommandParamSchema = z.object({ + key: z.enum(serverCommandKeys), +}); + +function getRequestAccessToken(request: FastifyRequest) { + const tokenHeader = request.headers['x-access-token']; + return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); +} + +function ensureAuthorized(request: FastifyRequest, reply: FastifyReply) { + if (getRequestAccessToken(request) === env.SERVER_COMMAND_ACCESS_TOKEN) { + return true; + } + + reply.status(403); + void reply.send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', + }); + return false; +} + +export async function registerServerCommandRoutes(app: FastifyInstance) { + app.get('/api/server-commands', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + return { + ok: true, + items: await listServerCommands(), + }; + }); + + app.post('/api/server-commands/:key/actions/restart', async (request, reply) => { + if (!ensureAuthorized(request, reply)) { + return; + } + + const { key } = serverCommandParamSchema.parse(request.params); + const result = await restartServerCommand(key); + + return { + ok: true, + item: result.server, + commandOutput: result.commandOutput, + restartState: result.restartState, + }; + }); +} diff --git a/etc/servers/work-server/src/routes/visitor-history.ts b/etc/servers/work-server/src/routes/visitor-history.ts new file mode 100755 index 0000000..977f6ec --- /dev/null +++ b/etc/servers/work-server/src/routes/visitor-history.ts @@ -0,0 +1,129 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; +import { + ensureVisitorHistoryTables, + getVisitorClientByClientId, + listVisitorClients, + listVisitorClientsQuerySchema, + listVisitorHistories, + trackVisit, + trackVisitSchema, + updateVisitorNickname, + updateVisitorNicknameSchema, + visitorHistoryQuerySchema, +} from '../services/visitor-history-service.js'; + +export async function registerVisitorHistoryRoutes(app: FastifyInstance) { + function requireHistoryAccessToken( + request: { headers: Record }, + reply: Parameters[1] extends (request: any, reply: infer T) => any ? T : any, + ) { + if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) { + return true; + } + + void reply.code(403).send({ + message: '๊ถŒํ•œ ํ† ํฐ์ด ๋“ฑ๋ก๋œ ์‚ฌ์šฉ์ž๋งŒ ๋ฐฉ๋ฌธ ์ด๋ ฅ์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + return false; + } + + app.post('/api/history/setup', async () => { + await ensureVisitorHistoryTables(); + + return { + ok: true, + }; + }); + + app.post('/api/history/track', async (request) => { + const bodyPayload = + request.body && typeof request.body === 'object' + ? (request.body as Record) + : {}; + const clientIdFromHeader = String(request.headers['x-client-id'] ?? '').trim(); + const payload = trackVisitSchema.parse({ + clientId: clientIdFromHeader || bodyPayload.clientId, + url: bodyPayload.url, + eventType: bodyPayload.eventType, + userAgent: + typeof bodyPayload.userAgent === 'string' + ? bodyPayload.userAgent + : String(request.headers['user-agent'] ?? ''), + }); + + const client = await trackVisit(payload, request.ip); + + return { + ok: true, + client, + }; + }); + + app.get('/api/history/visitors', async (request, reply) => { + if (!requireHistoryAccessToken(request, reply)) { + return; + } + + const query = listVisitorClientsQuerySchema.parse(request.query ?? {}); + const items = await listVisitorClients(query.limit ?? 100, { + search: query.search, + clientId: query.clientId, + nickname: query.nickname, + path: query.path, + visitedFrom: query.visitedFrom, + visitedTo: query.visitedTo, + }); + + return { + ok: true, + items, + }; + }); + + app.get('/api/history/visitors/:clientId', async (request, reply) => { + if (!requireHistoryAccessToken(request, reply)) { + return; + } + + const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId); + const query = visitorHistoryQuerySchema.parse(request.query ?? {}); + const client = await getVisitorClientByClientId(clientId); + + if (!client) { + return reply.code(404).send({ + message: '๋ฐฉ๋ฌธ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + const visits = await listVisitorHistories(clientId, query.limit ?? 200); + + return { + ok: true, + client, + visits, + }; + }); + + app.patch('/api/history/visitors/:clientId/nickname', async (request, reply) => { + if (!requireHistoryAccessToken(request, reply)) { + return; + } + + const clientId = z.string().trim().min(1).parse((request.params as { clientId: string }).clientId); + const payload = updateVisitorNicknameSchema.parse(request.body ?? {}); + const client = await updateVisitorNickname(clientId, payload.nickname); + + if (!client) { + return reply.code(404).send({ + message: '๋‹‰๋„ค์ž„์„ ์ˆ˜์ •ํ•  ๋ฐฉ๋ฌธ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }); + } + + return { + ok: true, + client, + }; + }); +} diff --git a/etc/servers/work-server/src/server.ts b/etc/servers/work-server/src/server.ts new file mode 100755 index 0000000..128f797 --- /dev/null +++ b/etc/servers/work-server/src/server.ts @@ -0,0 +1,47 @@ +import { env } from './config/env.js'; +import { db } from './db/client.js'; +import { createApp } from './app.js'; +import { ChatService } from './services/chat-service.js'; +import { clearAllChatConversationJobStates } from './services/chat-room-service.js'; +import { shutdownNotificationProvider } from './services/notification-service.js'; +import { PlanWorker } from './workers/plan-worker.js'; + +const app = createApp(); +const planWorker = new PlanWorker(app.log); +const chatService = new ChatService(app.log); +app.server.on('upgrade', chatService.attachUpgradeHandler()); + +async function start() { + try { + await clearAllChatConversationJobStates(); + await app.listen({ + host: '0.0.0.0', + port: env.PORT, + }); + planWorker.start(); + } catch (error) { + app.log.error(error); + process.exit(1); + } +} + +async function shutdown(signal: string) { + app.log.info(`Received ${signal}, closing server`); + + await planWorker.stop(); + chatService.close(); + await app.close(); + await shutdownNotificationProvider(); + await db.destroy(); + process.exit(0); +} + +process.on('SIGINT', () => { + void shutdown('SIGINT'); +}); + +process.on('SIGTERM', () => { + void shutdown('SIGTERM'); +}); + +void start(); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts new file mode 100755 index 0000000..a683734 --- /dev/null +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -0,0 +1,131 @@ +import { db } from '../db/client.js'; + +export const APP_CONFIG_TABLE = 'app_configs'; + +async function ensureAppConfigTable() { + const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); + + if (!hasTable) { + await db.schema.createTable(APP_CONFIG_TABLE, (table) => { + table.increments('id').primary(); + table.jsonb('config_json').notNullable().defaultTo('{}'); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(APP_CONFIG_TABLE, columnName); + if (!hasColumn) { + await db.schema.alterTable(APP_CONFIG_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function getAppConfig() { + await ensureAppConfigTable(); + + const row = await db(APP_CONFIG_TABLE).first(); + + if (!row) { + return null; + } + + if (typeof row.config_json === 'string') { + try { + return JSON.parse(row.config_json) as Record; + } catch { + return {}; + } + } + + return row.config_json ?? {}; +} + +export type AppConfigSnapshot = { + chat?: { + maxContextMessages?: number; + maxContextChars?: number; + }; + automation?: { + autoRefreshEnabled?: boolean; + autoRefreshIntervalSeconds?: number; + autoReceiveScheduleType?: 'interval' | 'daily' | 'weekly'; + autoReceiveIntervalSeconds?: number; + autoReceiveDailyTime?: string; + autoReceiveWeeklyDay?: 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'; + autoReceiveWeeklyTime?: string; + notifyOnAutomationStart?: boolean; + notifyOnAutomationProgress?: boolean; + notifyOnAutomationCompletion?: boolean; + notifyOnAutomationRelease?: boolean; + notifyOnAutomationMain?: boolean; + notifyOnAutomationFailure?: boolean; + notifyOnAutomationRestart?: boolean; + notifyOnAutomationIssueResolved?: boolean; + }; + worklogAutomation?: { + autoCreateDailyWorklog?: boolean; + dailyCreateTime?: string; + repeatRequestEnabled?: boolean; + repeatIntervalMinutes?: number; + includeScreenshots?: boolean; + includeChangedFiles?: boolean; + includeCommandLogs?: boolean; + template?: 'simple' | 'detailed'; + }; +}; + +export async function getAppConfigSnapshot(): Promise { + const raw = await getAppConfig(); + + if (!raw || typeof raw !== 'object') { + return {}; + } + + const snapshot = raw as AppConfigSnapshot; + + if (snapshot.worklogAutomation) { + return { + ...snapshot, + worklogAutomation: { + ...snapshot.worklogAutomation, + repeatRequestEnabled: false, + }, + }; + } + + return snapshot; +} + +export async function upsertAppConfig(config: Record) { + await ensureAppConfigTable(); + + const existing = await db(APP_CONFIG_TABLE).first(); + + if (!existing) { + const rows = await db(APP_CONFIG_TABLE) + .insert({ + config_json: config, + updated_at: db.fn.now(), + }) + .returning('*'); + return rows[0]?.config_json ?? config; + } + + const rows = await db(APP_CONFIG_TABLE) + .update({ + config_json: config, + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]?.config_json ?? config; +} diff --git a/etc/servers/work-server/src/services/board-service.test.ts b/etc/servers/work-server/src/services/board-service.test.ts new file mode 100644 index 0000000..1f35d0e --- /dev/null +++ b/etc/servers/work-server/src/services/board-service.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { BoardPostAutomationLockedError, buildBoardPostPlanNote } from './board-service.js'; + +test('buildBoardPostPlanNote formats automation work memo with clear sections', () => { + assert.equal( + buildBoardPostPlanNote(' ์•Œ๋ฆผ ๊ฐœ์„  ', '๋ณธ๋ฌธ ์ฒซ ์ค„\n๋ณธ๋ฌธ ๋‘˜์งธ ์ค„\n'), + [ + '# ์ž๋™ํ™” ์ž‘์—…๋ฉ”๋ชจ', + '', + '- ๊ฒŒ์‹œํŒ ์ œ๋ชฉ: ์•Œ๋ฆผ ๊ฐœ์„ ', + '- ๋ฉ”๋ชจ ์ถœ์ฒ˜: board_posts ์ž๋™ํ™” ์ ‘์ˆ˜', + '', + '## ์š”์ฒญ ๋ณธ๋ฌธ', + '๋ณธ๋ฌธ ์ฒซ ์ค„\n๋ณธ๋ฌธ ๋‘˜์งธ ์ค„', + ].join('\n'), + ); +}); + +test('BoardPostAutomationLockedError keeps user-facing message by action', () => { + assert.equal(new BoardPostAutomationLockedError('update').message, '์ž๋™ํ™” ์ ‘์ˆ˜๋œ ์ž‘์—…๋ฉ”๋ชจ๋Š” ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + assert.equal(new BoardPostAutomationLockedError('delete').message, '์ž๋™ํ™” ์ ‘์ˆ˜๋œ ์ž‘์—…๋ฉ”๋ชจ๋Š” ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); +}); diff --git a/etc/servers/work-server/src/services/board-service.ts b/etc/servers/work-server/src/services/board-service.ts new file mode 100755 index 0000000..07d58be --- /dev/null +++ b/etc/servers/work-server/src/services/board-service.ts @@ -0,0 +1,347 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; +import { + ensurePlanTable, + normalizePlanAutomationType, + PLAN_TABLE, + planAutomationTypeSchema, +} from './plan-service.js'; + +export const BOARD_POSTS_TABLE = 'board_posts'; + +export const boardPostPayloadSchema = z.object({ + title: z.string().trim().min(1).max(200), + content: z.string().min(1).max(200000), + automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), +}); + +export type BoardPostItem = { + id: number; + title: string; + content: string; + preview: string; + automationType: z.infer['automationType']; + automationPlanItemId: number | null; + automationReceivedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export class BoardPostAutomationLockedError extends Error { + constructor(action: 'update' | 'delete') { + super( + action === 'delete' + ? '์ž๋™ํ™” ์ ‘์ˆ˜๋œ ์ž‘์—…๋ฉ”๋ชจ๋Š” ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' + : '์ž๋™ํ™” ์ ‘์ˆ˜๋œ ์ž‘์—…๋ฉ”๋ชจ๋Š” ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + ); + this.name = 'BoardPostAutomationLockedError'; + } +} + +function createPreview(content: string) { + const normalized = content + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/[#>*_`~-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function mapBoardPostRow(row: Record): BoardPostItem { + const content = String(row.content ?? ''); + + return { + id: Number(row.id ?? 0), + title: String(row.title ?? ''), + content, + preview: createPreview(content), + automationType: normalizePlanAutomationType(row.automation_type), + automationPlanItemId: row.automation_plan_item_id === null || row.automation_plan_item_id === undefined + ? null + : Number(row.automation_plan_item_id), + automationReceivedAt: row.automation_received_at === null || row.automation_received_at === undefined + ? null + : String(row.automation_received_at), + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} + +function isBoardPostAutomationLocked(row: Record) { + return Boolean(row.automation_received_at || row.automation_plan_item_id); +} + +export function buildBoardPostPlanNote(title: string, content: string) { + const normalizedTitle = title.trim(); + const normalizedContent = content.trim(); + + return [ + '# ์ž๋™ํ™” ์ž‘์—…๋ฉ”๋ชจ', + '', + `- ๊ฒŒ์‹œํŒ ์ œ๋ชฉ: ${normalizedTitle}`, + '- ๋ฉ”๋ชจ ์ถœ์ฒ˜: board_posts ์ž๋™ํ™” ์ ‘์ˆ˜', + '', + '## ์š”์ฒญ ๋ณธ๋ฌธ', + normalizedContent, + ].join('\n'); +} + +function resolveInsertedId(result: unknown): number | null { + if (typeof result === 'number' && Number.isInteger(result) && result > 0) { + return result; + } + + if (Array.isArray(result)) { + const first = result[0]; + + if (typeof first === 'number' && Number.isInteger(first) && first > 0) { + return first; + } + + if (first && typeof first === 'object' && 'id' in first) { + const id = Number((first as { id?: unknown }).id); + return Number.isInteger(id) && id > 0 ? id : null; + } + } + + if (result && typeof result === 'object' && 'id' in result) { + const id = Number((result as { id?: unknown }).id); + return Number.isInteger(id) && id > 0 ? id : null; + } + + return null; +} + +function supportsReturning() { + const clientName = String(db.client.config.client ?? '').toLowerCase(); + return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName); +} + +function isDuplicateSchemaError(error: unknown, codes: string[], patterns: RegExp[]) { + const candidate = error as { code?: unknown; message?: unknown }; + const code = typeof candidate?.code === 'string' ? candidate.code : ''; + const message = typeof candidate?.message === 'string' ? candidate.message : ''; + + return codes.includes(code) || patterns.some((pattern) => pattern.test(message)); +} + +function isDuplicateTableError(error: unknown) { + return isDuplicateSchemaError(error, ['42P07'], [/already exists/i]); +} + +function isDuplicateColumnError(error: unknown) { + return isDuplicateSchemaError(error, ['42701'], [/already exists/i, /duplicate column/i]); +} + +export async function ensureBoardPostsTable() { + const hasTable = await db.schema.hasTable(BOARD_POSTS_TABLE); + + if (!hasTable) { + try { + await db.schema.createTable(BOARD_POSTS_TABLE, (table) => { + table.increments('id').primary(); + table.string('title', 200).notNullable(); + table.text('content').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + } catch (error) { + if (!isDuplicateTableError(error)) { + throw error; + } + } + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['title', (table) => table.string('title', 200).notNullable().defaultTo('์ œ๋ชฉ ์—†์Œ')], + ['content', (table) => table.text('content').notNullable().defaultTo('')], + ['automation_type', (table) => table.string('automation_type', 40).notNullable().defaultTo('none')], + ['automation_plan_item_id', (table) => table.integer('automation_plan_item_id').nullable()], + ['automation_received_at', (table) => table.timestamp('automation_received_at', { useTz: true }).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(BOARD_POSTS_TABLE, columnName); + + if (!hasColumn) { + try { + await db.schema.alterTable(BOARD_POSTS_TABLE, (table) => { + createColumn(table); + }); + } catch (error) { + if (!isDuplicateColumnError(error)) { + throw error; + } + } + } + } + + await db(BOARD_POSTS_TABLE) + .where({ automation_type: 'plan_registration' }) + .update({ automation_type: 'plan' }); + await db(BOARD_POSTS_TABLE) + .where({ automation_type: 'general_development' }) + .update({ automation_type: 'auto_worker' }); +} + +export async function listBoardPosts() { + await ensureBoardPostsTable(); + + const rows = await db(BOARD_POSTS_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); + return rows.map((row) => mapBoardPostRow(row)); +} + +export async function getBoardPost(id: number) { + await ensureBoardPostsTable(); + + const row = await db(BOARD_POSTS_TABLE).where({ id }).first(); + return row ? mapBoardPostRow(row) : null; +} + +export async function createBoardPost(payload: z.infer) { + await ensureBoardPostsTable(); + const parsedPayload = boardPostPayloadSchema.parse(payload); + const insertQuery = db(BOARD_POSTS_TABLE).insert({ + title: parsedPayload.title, + content: parsedPayload.content, + automation_type: parsedPayload.automationType, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }); + const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; + + const insertedId = resolveInsertedId(insertResult); + + if (!insertedId) { + throw new Error('๊ฒŒ์‹œ๊ธ€ ์ €์žฅ ํ›„ ID๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + const row = await db(BOARD_POSTS_TABLE).where({ id: insertedId }).first(); + + if (!row) { + throw new Error('์ €์žฅ๋œ ๊ฒŒ์‹œ๊ธ€์„ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + return mapBoardPostRow(row); +} + +export async function receiveBoardPostAutomation(id: number) { + await ensureBoardPostsTable(); + await ensurePlanTable(); + + return db.transaction(async (trx) => { + const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); + + if (!currentRow) { + return null; + } + + if (currentRow.automation_received_at || currentRow.automation_plan_item_id) { + return { + item: mapBoardPostRow(currentRow), + planItemId: + currentRow.automation_plan_item_id === null || currentRow.automation_plan_item_id === undefined + ? null + : Number(currentRow.automation_plan_item_id), + alreadyReceived: true, + }; + } + + const title = String(currentRow.title ?? '').trim(); + const content = String(currentRow.content ?? '').trim(); + const workId = `board-post-${id}`; + const insertQuery = trx(PLAN_TABLE).insert({ + work_id: workId, + note: buildBoardPostPlanNote(title, content), + automation_type: normalizePlanAutomationType(currentRow.automation_type), + status: '๋“ฑ๋ก', + release_target: 'release', + jangsing_processing_required: true, + auto_deploy_to_main: false, + worker_status: '๋Œ€๊ธฐ', + last_error: null, + updated_at: trx.fn.now(), + }); + const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; + const planItemId = resolveInsertedId(insertResult); + + if (!planItemId) { + throw new Error('์ž๋™ํ™” ์ ‘์ˆ˜ ํ›„ Plan ID๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + const updateQuery = trx(BOARD_POSTS_TABLE) + .where({ id }) + .update({ + automation_plan_item_id: planItemId, + automation_received_at: trx.fn.now(), + updated_at: trx.fn.now(), + }); + const updatedRows = supportsReturning() ? await updateQuery.returning('*') : []; + if (!supportsReturning()) { + await updateQuery; + } + + const updatedRow = updatedRows[0] ?? (await trx(BOARD_POSTS_TABLE).where({ id }).first()); + + if (!updatedRow) { + throw new Error('์ž๋™ํ™” ์ ‘์ˆ˜๋œ ๊ฒŒ์‹œ๊ธ€์„ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + return { + item: mapBoardPostRow(updatedRow), + planItemId, + alreadyReceived: false, + }; + }); +} + +export async function updateBoardPost(id: number, payload: z.infer) { + await ensureBoardPostsTable(); + const parsedPayload = boardPostPayloadSchema.parse(payload); + return db.transaction(async (trx) => { + const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); + + if (!currentRow) { + return null; + } + + if (isBoardPostAutomationLocked(currentRow)) { + throw new BoardPostAutomationLockedError('update'); + } + + await trx(BOARD_POSTS_TABLE) + .where({ id }) + .update({ + title: parsedPayload.title, + content: parsedPayload.content, + automation_type: parsedPayload.automationType, + updated_at: trx.fn.now(), + }); + + const row = await trx(BOARD_POSTS_TABLE).where({ id }).first(); + return row ? mapBoardPostRow(row) : null; + }); +} + +export async function deleteBoardPost(id: number) { + await ensureBoardPostsTable(); + return db.transaction(async (trx) => { + const currentRow = await trx(BOARD_POSTS_TABLE).where({ id }).forUpdate().first(); + + if (!currentRow) { + return false; + } + + if (isBoardPostAutomationLocked(currentRow)) { + throw new BoardPostAutomationLockedError('delete'); + } + + const deletedCount = await trx(BOARD_POSTS_TABLE).where({ id }).del(); + return deletedCount > 0; + }); +} diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts new file mode 100644 index 0000000..c50db03 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -0,0 +1,211 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildChatConversationRequestPatchFromMessage, + mergeChatConversationRequestStatus, + shouldClearConversationJobState, + selectChatConversationResponseCandidate, +} from './chat-room-service.js'; + +test('mergeChatConversationRequestStatus keeps terminal states from being downgraded', () => { + assert.equal(mergeChatConversationRequestStatus('completed', 'accepted'), 'completed'); + assert.equal(mergeChatConversationRequestStatus('started', 'accepted'), 'started'); + assert.equal(mergeChatConversationRequestStatus('queued', 'accepted'), 'queued'); + assert.equal(mergeChatConversationRequestStatus('accepted', 'completed'), 'completed'); +}); + +test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => { + assert.equal( + buildChatConversationRequestPatchFromMessage({ + id: 10, + author: 'system', + text: '์š”์ฒญ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.', + clientRequestId: 'chat-req-1', + }), + null, + ); +}); + +test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => { + assert.deepEqual( + buildChatConversationRequestPatchFromMessage({ + id: 11, + author: 'user', + text: '์งˆ๋ฌธ', + clientRequestId: 'chat-req-2', + }), + { + requestId: 'chat-req-2', + status: 'accepted', + userMessageId: 11, + userText: '์งˆ๋ฌธ', + }, + ); + + assert.deepEqual( + buildChatConversationRequestPatchFromMessage({ + id: 12, + author: 'codex', + text: '๋‹ต๋ณ€', + clientRequestId: 'chat-req-2', + }), + { + requestId: 'chat-req-2', + status: 'started', + responseMessageId: 12, + responseText: '๋‹ต๋ณ€', + }, + ); +}); + +test('selectChatConversationResponseCandidate falls back to codex replies in the request window', () => { + const candidate = selectChatConversationResponseCandidate( + { + requestId: 'chat-req-3', + createdAt: '2026-04-18T14:00:00.000Z', + responseMessageId: null, + }, + { + createdAt: '2026-04-18T14:10:00.000Z', + }, + [ + { + id: 1, + messageId: 1001, + author: 'codex', + text: '์ด์ „ ๋‹ต๋ณ€', + clientRequestId: null, + createdAt: '2026-04-18T13:59:00.000Z', + }, + { + id: 2, + messageId: 1002, + author: 'codex', + text: 'ํ˜„์žฌ ์š”์ฒญ ๋‹ต๋ณ€', + clientRequestId: null, + createdAt: '2026-04-18T14:05:00.000Z', + }, + { + id: 3, + messageId: 1003, + author: 'codex', + text: '๋‹ค์Œ ์š”์ฒญ ๋‹ต๋ณ€', + clientRequestId: null, + createdAt: '2026-04-18T14:11:00.000Z', + }, + ], + ); + + assert.deepEqual(candidate, { + id: 2, + messageId: 1002, + author: 'codex', + text: 'ํ˜„์žฌ ์š”์ฒญ ๋‹ต๋ณ€', + clientRequestId: null, + createdAt: '2026-04-18T14:05:00.000Z', + }); +}); + +test('shouldClearConversationJobState clears stale job state when terminal request already has a response', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-4', + currentJobStatus: 'started', + request: { + requestId: 'chat-req-4', + status: 'completed', + responseMessageId: 101, + responseText: '๋‹ต๋ณ€', + terminalAt: '2026-04-19T01:00:00.000Z', + }, + }), + true, + ); +}); + +test('shouldClearConversationJobState clears orphaned job state without a current request id', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: null, + currentJobStatus: 'started', + request: null, + }), + true, + ); +}); + +test('shouldClearConversationJobState keeps active job state when request is still running without a response', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-5', + currentJobStatus: 'started', + request: { + requestId: 'chat-req-5', + status: 'started', + responseMessageId: null, + responseText: '', + terminalAt: null, + }, + }), + false, + ); +}); + +test('shouldClearConversationJobState does not clear placeholder-only started responses', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-6', + currentJobStatus: 'started', + request: { + requestId: 'chat-req-6', + status: 'started', + responseMessageId: 301, + responseText: '์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...', + terminalAt: null, + }, + }), + false, + ); +}); + +test('shouldClearConversationJobState clears stale placeholder-only started responses when runtime is gone', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-7', + currentJobStatus: 'started', + currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z', + runtimeActive: false, + nowMs: Date.parse('2026-04-19T08:11:36.000Z'), + request: { + requestId: 'chat-req-7', + status: 'started', + responseMessageId: 302, + responseText: '์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...', + terminalAt: null, + updatedAt: '2026-04-19T08:08:56.086Z', + }, + }), + true, + ); +}); + +test('shouldClearConversationJobState keeps placeholder-only started responses while runtime is active', () => { + assert.equal( + shouldClearConversationJobState({ + currentRequestId: 'chat-req-8', + currentJobStatus: 'started', + currentStatusUpdatedAt: '2026-04-19T08:08:35.813Z', + runtimeActive: true, + nowMs: Date.parse('2026-04-19T08:11:36.000Z'), + request: { + requestId: 'chat-req-8', + status: 'started', + responseMessageId: 303, + responseText: '์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...', + terminalAt: null, + updatedAt: '2026-04-19T08:08:56.086Z', + }, + }), + false, + ); +}); diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts new file mode 100644 index 0000000..5850914 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -0,0 +1,1967 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; +import { chatRuntimeService } from './chat-runtime-service.js'; + +export const CHAT_CONVERSATION_TABLE = 'chat_conversations'; +export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages'; +export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients'; +export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests'; +export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities'; +const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; +const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; + +const conversationPayloadSchema = z.object({ + sessionId: z.string().trim().min(1).max(120), + clientId: z.string().trim().max(120).nullable().optional(), + title: z.string().trim().max(200).nullable().optional(), + contextLabel: z.string().trim().max(200).nullable().optional(), + contextDescription: z.string().trim().max(2000).nullable().optional(), + notifyOffline: z.boolean().optional(), +}); + +const conversationMessagePayloadSchema = z.object({ + sessionId: z.string().trim().min(1).max(120), + messageId: z.number().int().positive(), + author: z.enum(['codex', 'system', 'user']), + text: z.string().max(200000), + timestamp: z.string().trim().max(40), + clientRequestId: z.string().trim().max(120).nullable().optional(), +}); + +export type ChatConversationItem = { + sessionId: string; + clientId: string | null; + title: string; + contextLabel: string | null; + contextDescription: string | null; + notifyOffline: boolean; + hasUnreadResponse: boolean; + currentRequestId: string | null; + currentJobStatus: 'queued' | 'started' | 'completed' | 'failed' | null; + currentJobMessage: string | null; + currentQueueSize: number; + currentStatusUpdatedAt: string | null; + lastMessagePreview: string; + createdAt: string; + updatedAt: string; + lastMessageAt: string | null; +}; + +export type StoredChatMessage = { + id: number; + author: 'codex' | 'system' | 'user'; + text: string; + timestamp: string; + clientRequestId?: string | null; +}; + +export type ChatConversationRequestStatus = + | 'accepted' + | 'queued' + | 'started' + | 'completed' + | 'failed' + | 'cancelled' + | 'removed'; + +export type ChatConversationRequestItem = { + sessionId: string; + requestId: string; + status: ChatConversationRequestStatus; + statusMessage: string | null; + userMessageId: number | null; + userText: string; + responseMessageId: number | null; + responseText: string; + hasResponse: boolean; + canDelete: boolean; + createdAt: string; + updatedAt: string; + answeredAt: string | null; + terminalAt: string | null; +}; + +export type ChatConversationActivityLogItem = { + sessionId: string; + requestId: string; + lines: string[]; + updatedAt: string | null; +}; + +type ChatConversationRequestStatusPatch = { + requestId: string; + status?: ChatConversationRequestStatus; + userMessageId?: number | null; + userText?: string | null; + responseMessageId?: number | null; + responseText?: string | null; +}; + +type ChatConversationResponseCandidate = { + id: number; + messageId: number; + author: StoredChatMessage['author']; + text: string; + clientRequestId: string | null; + createdAt: string | null; +}; + +type ChatConversationClientPreference = { + sessionId: string; + clientId: string; + notifyOffline: boolean; + lastReadResponseMessageId: number | null; +}; + +function createPreview(text: string) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function isPreviewableConversationMessage(row: { author?: unknown; text?: unknown }) { + const author = String(row.author ?? ''); + const text = String(row.text ?? '').trim(); + + if (!text) { + return false; + } + + if (author === 'system' && text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`)) { + return false; + } + + return author === 'user' || author === 'codex'; +} + +function buildConversationTitle(text: string) { + const preview = createPreview(text); + return preview || '์ƒˆ ๋Œ€ํ™”'; +} + +function mapConversationRow(row: Record): ChatConversationItem { + return { + sessionId: String(row.session_id ?? ''), + clientId: row.client_id == null ? null : String(row.client_id), + title: String(row.title ?? '์ƒˆ ๋Œ€ํ™”'), + contextLabel: row.context_label == null ? null : String(row.context_label), + contextDescription: row.context_description == null ? null : String(row.context_description), + notifyOffline: Boolean(row.notify_offline), + hasUnreadResponse: Boolean(row.has_unread_response), + currentRequestId: row.current_request_id == null ? null : String(row.current_request_id), + currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], + currentJobMessage: row.current_job_message == null ? null : String(row.current_job_message), + currentQueueSize: Number(row.current_queue_size ?? 0), + currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + lastMessagePreview: String(row.last_message_preview ?? ''), + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + lastMessageAt: row.last_message_at == null ? null : String(row.last_message_at), + }; +} + +function mapMessageRow(row: Record): StoredChatMessage { + return { + id: Number(row.message_id ?? row.id ?? 0), + author: String(row.author ?? 'codex') as StoredChatMessage['author'], + text: String(row.text ?? ''), + timestamp: String(row.display_timestamp ?? ''), + clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), + }; +} + +function isVisibleConversationMessage(message: StoredChatMessage) { + if (message.author !== 'system') { + return true; + } + + return message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); +} + +function mapClientPreferenceRow(row: Record): ChatConversationClientPreference { + return { + sessionId: String(row.session_id ?? ''), + clientId: String(row.client_id ?? ''), + notifyOffline: Boolean(row.notify_offline), + lastReadResponseMessageId: + row.last_read_response_message_id == null ? null : Number(row.last_read_response_message_id), + }; +} + +function mapRequestRow(row: Record): ChatConversationRequestItem { + const status = String(row.status ?? 'accepted') as ChatConversationRequestStatus; + const hasResponse = row.response_message_id != null || String(row.response_text ?? '').trim().length > 0; + const canDelete = !hasResponse && !['queued', 'started', 'completed'].includes(status); + + return { + sessionId: String(row.session_id ?? ''), + requestId: String(row.request_id ?? ''), + status, + statusMessage: row.status_message == null ? null : String(row.status_message), + userMessageId: row.user_message_id == null ? null : Number(row.user_message_id), + userText: String(row.user_text ?? ''), + responseMessageId: row.response_message_id == null ? null : Number(row.response_message_id), + responseText: String(row.response_text ?? ''), + hasResponse, + canDelete, + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + answeredAt: row.answered_at == null ? null : String(row.answered_at), + terminalAt: row.terminal_at == null ? null : String(row.terminal_at), + }; +} + +function normalizeClientId(clientId?: string | null) { + return clientId?.trim() || null; +} + +function getTimeValue(value: string | null | undefined) { + if (!value) { + return 0; + } + + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : 0; +} + +function isRuntimeRequestActive(requestId?: string | null) { + const normalizedRequestId = requestId?.trim() || null; + + if (!normalizedRequestId) { + return false; + } + + const detail = chatRuntimeService.getJobDetail(normalizedRequestId); + return detail.item != null && detail.terminalStatus == null; +} + +function isTerminalRequestStatus(status: ChatConversationRequestStatus | null | undefined) { + return status === 'completed' || status === 'failed' || status === 'cancelled' || status === 'removed'; +} + +function isPreparingChatReplyText(text?: string | null) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.startsWith('์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค'); +} + +function hasStoredRequestResponse(request: { + responseMessageId?: number | null; + responseText?: string | null; +}) { + const normalizedResponseText = String(request.responseText ?? '').trim(); + + if (normalizedResponseText.length > 0) { + return !isPreparingChatReplyText(normalizedResponseText); + } + + return request.responseMessageId != null; +} + +function isConversationRequestActive( + conversation: { + current_request_id?: unknown; + current_job_status?: unknown; + } | null | undefined, + requestId?: string | null, +) { + const normalizedRequestId = requestId?.trim() || null; + + if (!normalizedRequestId) { + return false; + } + + const currentRequestId = String(conversation?.current_request_id ?? '').trim() || null; + const currentJobStatus = String(conversation?.current_job_status ?? '').trim(); + + if (currentRequestId !== normalizedRequestId) { + return false; + } + + return currentJobStatus === 'queued' || currentJobStatus === 'started'; +} + +function normalizeStaleRequestItem( + item: ChatConversationRequestItem, + conversation: { + current_request_id?: unknown; + current_job_status?: unknown; + current_status_updated_at?: unknown; + } | null | undefined, +) { + const runtimeActive = isRuntimeRequestActive(item.requestId); + + if ( + (item.status === 'queued' || item.status === 'started') && + !item.hasResponse && + !isConversationRequestActive(conversation, item.requestId) + ) { + return { + ...item, + status: 'failed' as const, + statusMessage: item.statusMessage ?? '์ค‘๋‹จ๋œ ์˜ค๋ž˜๋œ ์š”์ฒญ', + canDelete: true, + terminalAt: item.terminalAt ?? item.updatedAt, + }; + } + + if ( + shouldClearConversationJobState({ + currentRequestId: String(conversation?.current_request_id ?? ''), + currentJobStatus: + conversation?.current_job_status == null + ? null + : String(conversation.current_job_status) as ChatConversationItem['currentJobStatus'], + currentStatusUpdatedAt: + conversation?.current_status_updated_at == null ? null : String(conversation.current_status_updated_at), + runtimeActive, + request: item, + }) + ) { + return { + ...item, + status: 'failed' as const, + statusMessage: item.statusMessage ?? '์ค‘๋‹จ๋œ ์˜ค๋ž˜๋œ ์š”์ฒญ', + canDelete: true, + terminalAt: item.terminalAt ?? item.updatedAt, + }; + } + + return item; +} + +export function shouldClearConversationJobState(params: { + currentRequestId?: string | null; + currentJobStatus?: ChatConversationItem['currentJobStatus']; + currentStatusUpdatedAt?: string | null; + runtimeActive?: boolean; + nowMs?: number; + request: + | { + requestId?: string | null; + status?: ChatConversationRequestStatus | null; + responseMessageId?: number | null; + responseText?: string | null; + terminalAt?: string | null; + updatedAt?: string | null; + } + | null + | undefined; +}) { + const currentJobStatus = params.currentJobStatus ?? null; + const requestStatus = params.request?.status ?? null; + const hasStoredResponse = hasStoredRequestResponse(params.request ?? {}); + + if (!currentJobStatus) { + return false; + } + + const currentRequestId = params.currentRequestId?.trim() || null; + + if (!currentRequestId) { + return true; + } + + const requestId = params.request?.requestId?.trim() || null; + + if (!requestId || requestId !== currentRequestId) { + return false; + } + + const runtimeActive = params.runtimeActive === true; + const lastUpdatedAt = Math.max( + getTimeValue(params.currentStatusUpdatedAt), + getTimeValue(params.request?.updatedAt), + ); + const nowMs = Number.isFinite(params.nowMs) ? Number(params.nowMs) : Date.now(); + const isStaleInProgressState = + !runtimeActive && + (currentJobStatus === 'queued' || currentJobStatus === 'started') && + !hasStoredRequestResponse(params.request ?? {}) && + !isTerminalRequestStatus(params.request?.status ?? null) && + lastUpdatedAt > 0 && + nowMs - lastUpdatedAt >= STALE_CHAT_REQUEST_TIMEOUT_MS; + + return ( + (requestStatus != null && requestStatus !== 'completed' && isTerminalRequestStatus(requestStatus)) || + hasStoredResponse || + isStaleInProgressState + ); +} + +function getRequestStatusRank(status: ChatConversationRequestStatus | null | undefined) { + switch (status) { + case 'accepted': + return 0; + case 'queued': + return 1; + case 'started': + return 2; + case 'completed': + case 'failed': + case 'cancelled': + case 'removed': + return 3; + default: + return -1; + } +} + +export function mergeChatConversationRequestStatus( + currentStatus: ChatConversationRequestStatus | null | undefined, + incomingStatus: ChatConversationRequestStatus | null | undefined, +): ChatConversationRequestStatus { + const normalizedCurrent = currentStatus ?? null; + const normalizedIncoming = incomingStatus ?? null; + + if (!normalizedCurrent && !normalizedIncoming) { + return 'accepted'; + } + + if (!normalizedCurrent) { + return normalizedIncoming ?? 'accepted'; + } + + if (!normalizedIncoming) { + return normalizedCurrent; + } + + if (isTerminalRequestStatus(normalizedCurrent) && !isTerminalRequestStatus(normalizedIncoming)) { + return normalizedCurrent; + } + + if (!isTerminalRequestStatus(normalizedCurrent) && isTerminalRequestStatus(normalizedIncoming)) { + return normalizedIncoming; + } + + return getRequestStatusRank(normalizedIncoming) >= getRequestStatusRank(normalizedCurrent) + ? normalizedIncoming + : normalizedCurrent; +} + +export function buildChatConversationRequestPatchFromMessage(message: { + id: number; + author: StoredChatMessage['author']; + text: string; + clientRequestId?: string | null; +}): ChatConversationRequestStatusPatch | null { + const normalizedRequestId = message.clientRequestId?.trim() || null; + + if (!normalizedRequestId) { + return null; + } + + if (message.author === 'user') { + return { + requestId: normalizedRequestId, + status: 'accepted', + userMessageId: message.id, + userText: message.text, + }; + } + + if (message.author === 'codex') { + return { + requestId: normalizedRequestId, + status: 'started', + responseMessageId: message.id, + responseText: message.text, + }; + } + + return null; +} + +export function selectChatConversationResponseCandidate( + request: { + requestId: string; + createdAt: string; + responseMessageId?: number | null; + }, + nextRequest: { + createdAt: string; + } | undefined, + messages: ChatConversationResponseCandidate[], +) { + const normalizedRequestId = request.requestId.trim(); + + if (!normalizedRequestId) { + return null; + } + + const directMatches = messages.filter( + (message) => message.author === 'codex' && message.clientRequestId?.trim() === normalizedRequestId, + ); + + if (directMatches.length > 0) { + return directMatches.at(-1) ?? null; + } + + if (request.responseMessageId != null) { + const responseMessageMatch = messages.find( + (message) => message.author === 'codex' && message.messageId === request.responseMessageId, + ); + + if (responseMessageMatch) { + return responseMessageMatch; + } + } + + const requestCreatedAt = getTimeValue(request.createdAt); + const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); + const windowMatches = messages.filter((message) => { + if (message.author !== 'codex') { + return false; + } + + const linkedRequestId = message.clientRequestId?.trim() || null; + + if (linkedRequestId && linkedRequestId !== normalizedRequestId) { + return false; + } + + const createdAt = getTimeValue(message.createdAt); + + if (requestCreatedAt > 0 && createdAt > 0 && createdAt < requestCreatedAt) { + return false; + } + + if (nextRequestCreatedAt > 0 && createdAt > 0 && createdAt >= nextRequestCreatedAt) { + return false; + } + + return Boolean(String(message.text ?? '').trim()); + }); + + return windowMatches.at(-1) ?? null; +} + +async function getLatestPreviewableMessageMap(sessionIds: string[]) { + const normalizedSessionIds = sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean); + + if (normalizedSessionIds.length === 0) { + return new Map(); + } + + const rows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .select('session_id', 'author', 'text', 'created_at') + .whereIn('session_id', normalizedSessionIds) + .whereIn('author', ['user', 'codex']) + .orderBy('session_id', 'asc') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); + + const messageMap = new Map(); + + for (const row of rows) { + const sessionId = String(row.session_id ?? '').trim(); + + if (!sessionId || messageMap.has(sessionId) || !isPreviewableConversationMessage(row)) { + continue; + } + + messageMap.set(sessionId, { + text: String(row.text ?? ''), + createdAt: row.created_at == null ? null : String(row.created_at), + }); + } + + return messageMap; +} + +async function getLatestRequestPreviewMap(sessionIds: string[]) { + const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); + + if (normalizedSessionIds.length === 0) { + return new Map(); + } + + const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('session_id', 'user_text', 'created_at', 'status', 'request_id') + .whereIn('session_id', normalizedSessionIds) + .whereNot('status', 'removed') + .orderBy('session_id', 'asc') + .orderBy('created_at', 'desc') + .orderBy('request_id', 'desc'); + + const requestMap = new Map(); + + for (const row of rows) { + const sessionId = String(row.session_id ?? '').trim(); + const userText = String(row.user_text ?? '').trim(); + + if (!sessionId || requestMap.has(sessionId) || !userText) { + continue; + } + + requestMap.set(sessionId, { + text: userText, + createdAt: row.created_at == null ? null : String(row.created_at), + }); + } + + return requestMap; +} + +function resolveConversationPreviewOverride( + mapped: ChatConversationItem, + latestMessage: { text: string; createdAt: string | null } | undefined, + latestRequest: { text: string; createdAt: string | null } | undefined, +) { + const latestMessageTime = getTimeValue(latestMessage?.createdAt); + const latestRequestTime = getTimeValue(latestRequest?.createdAt); + + if (latestRequest && latestRequestTime > latestMessageTime) { + return { + ...mapped, + lastMessagePreview: createPreview(latestRequest.text), + lastMessageAt: latestRequest.createdAt, + }; + } + + if (latestMessage) { + return { + ...mapped, + lastMessagePreview: createPreview(latestMessage.text), + lastMessageAt: latestMessage.createdAt, + }; + } + + return mapped; +} + +async function getLatestResponseMessageIdMap(sessionIds: string[]) { + const normalizedSessionIds = Array.from(new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean))); + + if (normalizedSessionIds.length === 0) { + return new Map(); + } + + const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('session_id', 'response_message_id') + .whereIn('session_id', normalizedSessionIds) + .whereNotNull('response_message_id') + .orderBy('session_id', 'asc') + .orderBy('response_message_id', 'desc'); + + const responseMap = new Map(); + + for (const row of rows) { + const sessionId = String(row.session_id ?? '').trim(); + const responseMessageId = + row.response_message_id == null ? null : Number(row.response_message_id); + + if (!sessionId || responseMessageId == null || responseMap.has(sessionId)) { + continue; + } + + responseMap.set(sessionId, responseMessageId); + } + + return responseMap; +} + +async function getLatestResponseMessageId(sessionId: string) { + const responseMap = await getLatestResponseMessageIdMap([sessionId]); + return responseMap.get(sessionId.trim()) ?? null; +} + +export async function ensureChatConversationTables() { + const hasConversationTable = await db.schema.hasTable(CHAT_CONVERSATION_TABLE); + + if (!hasConversationTable) { + await db.schema.createTable(CHAT_CONVERSATION_TABLE, (table) => { + table.string('session_id', 120).primary(); + table.string('client_id', 120).nullable().index(); + table.string('title', 200).notNullable().defaultTo('์ƒˆ ๋Œ€ํ™”'); + table.string('context_label', 200).nullable(); + table.text('context_description').nullable(); + table.boolean('notify_offline').notNullable().defaultTo(false); + table.string('current_request_id', 120).nullable(); + table.string('current_job_status', 40).nullable(); + table.text('current_job_message').nullable(); + table.integer('current_queue_size').notNullable().defaultTo(0); + table.timestamp('current_status_updated_at', { useTz: true }).nullable(); + table.text('last_message_preview').notNullable().defaultTo(''); + table.timestamp('last_message_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + } + + const requiredConversationColumns: Array<[string, (table: any) => void]> = [ + ['client_id', (table) => table.string('client_id', 120).nullable().index()], + ['title', (table) => table.string('title', 200).notNullable().defaultTo('์ƒˆ ๋Œ€ํ™”')], + ['context_label', (table) => table.string('context_label', 200).nullable()], + ['context_description', (table) => table.text('context_description').nullable()], + ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], + ['current_request_id', (table) => table.string('current_request_id', 120).nullable()], + ['current_job_status', (table) => table.string('current_job_status', 40).nullable()], + ['current_job_message', (table) => table.text('current_job_message').nullable()], + ['current_queue_size', (table) => table.integer('current_queue_size').notNullable().defaultTo(0)], + ['current_status_updated_at', (table) => table.timestamp('current_status_updated_at', { useTz: true }).nullable()], + ['last_message_preview', (table) => table.text('last_message_preview').notNullable().defaultTo('')], + ['last_message_at', (table) => table.timestamp('last_message_at', { useTz: true }).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredConversationColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_TABLE, (table) => { + createColumn(table); + }); + } + } + + const hasClientTable = await db.schema.hasTable(CHAT_CONVERSATION_CLIENT_TABLE); + + if (!hasClientTable) { + await db.schema.createTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { + table.increments('id').primary(); + table.string('session_id', 120).notNullable().index(); + table.string('client_id', 120).notNullable().index(); + table.boolean('notify_offline').notNullable().defaultTo(false); + table.bigInteger('last_read_response_message_id').nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['session_id', 'client_id']); + }); + } + + const requiredClientColumns: Array<[string, (table: any) => void]> = [ + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['client_id', (table) => table.string('client_id', 120).notNullable().index()], + ['notify_offline', (table) => table.boolean('notify_offline').notNullable().defaultTo(false)], + ['last_read_response_message_id', (table) => table.bigInteger('last_read_response_message_id').nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredClientColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_CLIENT_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_CLIENT_TABLE, (table) => { + createColumn(table); + }); + } + } + + const hasMessageTable = await db.schema.hasTable(CHAT_CONVERSATION_MESSAGE_TABLE); + + if (!hasMessageTable) { + await db.schema.createTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { + table.increments('id').primary(); + table.string('session_id', 120).notNullable().index(); + table.bigInteger('message_id').notNullable(); + table.string('author', 20).notNullable(); + table.text('text').notNullable(); + table.string('display_timestamp', 40).notNullable().defaultTo(''); + table.string('client_request_id', 120).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['session_id', 'message_id']); + }); + } + + const requiredMessageColumns: Array<[string, (table: any) => void]> = [ + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['message_id', (table) => table.bigInteger('message_id').notNullable()], + ['author', (table) => table.string('author', 20).notNullable().defaultTo('codex')], + ['text', (table) => table.text('text').notNullable().defaultTo('')], + ['display_timestamp', (table) => table.string('display_timestamp', 40).notNullable().defaultTo('')], + ['client_request_id', (table) => table.string('client_request_id', 120).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredMessageColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_MESSAGE_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_MESSAGE_TABLE, (table) => { + createColumn(table); + }); + } + } + + const hasRequestTable = await db.schema.hasTable(CHAT_CONVERSATION_REQUEST_TABLE); + + if (!hasRequestTable) { + await db.schema.createTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { + table.increments('id').primary(); + table.string('session_id', 120).notNullable().index(); + table.string('request_id', 120).notNullable(); + table.string('status', 40).notNullable().defaultTo('accepted'); + table.text('status_message').nullable(); + table.bigInteger('user_message_id').nullable(); + table.text('user_text').notNullable().defaultTo(''); + table.bigInteger('response_message_id').nullable(); + table.text('response_text').notNullable().defaultTo(''); + table.timestamp('answered_at', { useTz: true }).nullable(); + table.timestamp('terminal_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['session_id', 'request_id']); + }); + } + + const requiredRequestColumns: Array<[string, (table: any) => void]> = [ + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['request_id', (table) => table.string('request_id', 120).notNullable()], + ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')], + ['status_message', (table) => table.text('status_message').nullable()], + ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()], + ['user_text', (table) => table.text('user_text').notNullable().defaultTo('')], + ['response_message_id', (table) => table.bigInteger('response_message_id').nullable()], + ['response_text', (table) => table.text('response_text').notNullable().defaultTo('')], + ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], + ['terminal_at', (table) => table.timestamp('terminal_at', { useTz: true }).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredRequestColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_REQUEST_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_REQUEST_TABLE, (table) => { + createColumn(table); + }); + } + } + + const hasActivityTable = await db.schema.hasTable(CHAT_CONVERSATION_ACTIVITY_TABLE); + + if (!hasActivityTable) { + await db.schema.createTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { + table.increments('id').primary(); + table.string('session_id', 120).notNullable().index(); + table.string('request_id', 120).notNullable().index(); + table.integer('line_no').notNullable(); + table.text('text').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['session_id', 'request_id', 'line_no']); + }); + } + + const requiredActivityColumns: Array<[string, (table: any) => void]> = [ + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['request_id', (table) => table.string('request_id', 120).notNullable().index()], + ['line_no', (table) => table.integer('line_no').notNullable()], + ['text', (table) => table.text('text').notNullable().defaultTo('')], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredActivityColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_ACTIVITY_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_ACTIVITY_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function getChatConversation(sessionId: string, clientId?: string | null) { + await ensureChatConversationTables(); + const normalizedSessionId = sessionId.trim(); + let row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); + + if (!row) { + return null; + } + + const currentRequestId = String(row.current_request_id ?? '').trim() || null; + + if ( + shouldClearConversationJobState({ + currentRequestId, + currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], + currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + runtimeActive: isRuntimeRequestActive(currentRequestId), + request: currentRequestId + ? await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: currentRequestId, + }) + .first() + .then((requestRow) => + requestRow + ? { + requestId: String(requestRow.request_id ?? ''), + status: String(requestRow.status ?? '') as ChatConversationRequestStatus, + responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), + responseText: String(requestRow.response_text ?? ''), + terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at), + updatedAt: requestRow.updated_at == null ? null : String(requestRow.updated_at), + } + : null, + ) + : null, + }) + ) { + const shouldMarkRequestFailed = + currentRequestId && + !isRuntimeRequestActive(currentRequestId) && + ['queued', 'started'].includes(String(row.current_job_status ?? '').trim()); + + if (shouldMarkRequestFailed) { + await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: currentRequestId, + }) + .whereIn('status', ['queued', 'started']) + .update({ + status: 'failed', + status_message: '์ค‘๋‹จ๋œ ์˜ค๋ž˜๋œ ์š”์ฒญ', + terminal_at: db.fn.now(), + updated_at: db.fn.now(), + }); + } + + await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .update({ + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); + row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); + + if (!row) { + return null; + } + } + + const mapped = mapConversationRow(row); + const latestPreviewMessageMap = await getLatestPreviewableMessageMap([normalizedSessionId]); + const latestRequestPreviewMap = await getLatestRequestPreviewMap([normalizedSessionId]); + const previewResolvedConversation = resolveConversationPreviewOverride( + mapped, + latestPreviewMessageMap.get(normalizedSessionId), + latestRequestPreviewMap.get(normalizedSessionId), + ); + const normalizedClientId = normalizeClientId(clientId); + + if (!normalizedClientId) { + return previewResolvedConversation; + } + + const preference = await getChatConversationClientPreference(sessionId, normalizedClientId); + const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); + return { + ...previewResolvedConversation, + clientId: normalizedClientId, + notifyOffline: preference?.notifyOffline ?? previewResolvedConversation.notifyOffline, + hasUnreadResponse: + latestResponseMessageId != null && + latestResponseMessageId > (preference?.lastReadResponseMessageId ?? 0), + }; +} + +export async function createChatConversation(payload: z.input) { + await ensureChatConversationTables(); + const parsed = conversationPayloadSchema.parse(payload); + const normalizedClientId = normalizeClientId(parsed.clientId); + const notifyOffline = parsed.notifyOffline ?? true; + await db(CHAT_CONVERSATION_TABLE) + .insert({ + session_id: parsed.sessionId, + client_id: normalizedClientId, + title: parsed.title?.trim() || '์ƒˆ ๋Œ€ํ™”', + context_label: parsed.contextLabel?.trim() || null, + context_description: parsed.contextDescription?.trim() || null, + notify_offline: notifyOffline, + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: null, + last_message_preview: '', + last_message_at: null, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .onConflict('session_id') + .ignore(); + + if (normalizedClientId) { + const existingPreference = await getChatConversationClientPreference(parsed.sessionId, normalizedClientId); + + if (!existingPreference) { + await upsertChatConversationClientPreference(parsed.sessionId, normalizedClientId, notifyOffline); + } + } + + const conversation = await getChatConversation(parsed.sessionId, normalizedClientId); + + if (!conversation) { + throw new Error('์ฑ„ํŒ…๋ฐฉ์„ ์ €์žฅํ–ˆ์ง€๋งŒ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + return conversation; +} + +export async function updateChatConversationContext( + sessionId: string, + payload: { + title?: string | null; + clientId?: string | null; + contextLabel?: string | null; + contextDescription?: string | null; + notifyOffline?: boolean | null; + }, +) { + await ensureChatConversationTables(); + const normalizedClientId = normalizeClientId(payload.clientId); + const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); + + if (!current) { + return null; + } + + await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: sessionId.trim() }) + .update({ + title: payload.title?.trim() || current.title || '์ƒˆ ๋Œ€ํ™”', + client_id: normalizedClientId || current.client_id || null, + context_label: payload.contextLabel?.trim() || null, + context_description: payload.contextDescription?.trim() || null, + notify_offline: + normalizedClientId == null && payload.notifyOffline != null + ? payload.notifyOffline + : Boolean(current.notify_offline), + updated_at: db.fn.now(), + }); + + if (normalizedClientId && payload.notifyOffline != null) { + await upsertChatConversationClientPreference(sessionId, normalizedClientId, payload.notifyOffline); + } + + return getChatConversation(sessionId, normalizedClientId); +} + +export async function listChatConversations( + clientId?: string | null, + limit = 50, + unreadStateClientId?: string | null, +) { + await ensureChatConversationTables(); + const normalizedClientId = normalizeClientId(clientId); + const normalizedUnreadStateClientId = normalizeClientId(unreadStateClientId ?? clientId); + const query = db(CHAT_CONVERSATION_TABLE) + .select('*') + .orderByRaw('COALESCE(last_message_at, updated_at, created_at) DESC NULLS LAST') + .orderByRaw('last_message_at DESC NULLS LAST') + .orderByRaw('updated_at DESC NULLS LAST') + .orderByRaw('created_at DESC NULLS LAST') + .limit(Math.max(1, Math.min(200, Math.round(limit)))); + + if (normalizedClientId) { + query.where((builder) => { + builder + .where({ client_id: normalizedClientId }) + .orWhereExists( + db(CHAT_CONVERSATION_CLIENT_TABLE) + .select(db.raw('1')) + .whereRaw(`${CHAT_CONVERSATION_CLIENT_TABLE}.session_id = ${CHAT_CONVERSATION_TABLE}.session_id`) + .andWhere({ client_id: normalizedClientId }), + ); + }); + } + + let rows = await query; + + const sessionIds = rows.map((row) => String(row.session_id ?? '')).filter(Boolean); + const currentRequestIds = Array.from( + new Set(rows.map((row) => String(row.current_request_id ?? '').trim()).filter(Boolean)), + ); + + if (sessionIds.length > 0 && currentRequestIds.length > 0) { + const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('session_id', 'request_id', 'status', 'response_message_id', 'response_text', 'terminal_at') + .whereIn('session_id', sessionIds) + .whereIn('request_id', currentRequestIds); + const requestMap = new Map( + requestRows.map((requestRow) => [ + `${String(requestRow.session_id ?? '').trim()}:${String(requestRow.request_id ?? '').trim()}`, + { + requestId: String(requestRow.request_id ?? ''), + status: String(requestRow.status ?? '') as ChatConversationRequestStatus, + responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), + responseText: String(requestRow.response_text ?? ''), + terminalAt: requestRow.terminal_at == null ? null : String(requestRow.terminal_at), + }, + ]), + ); + const staleSessionIds = rows + .filter((row) => + shouldClearConversationJobState({ + currentRequestId: String(row.current_request_id ?? ''), + currentJobStatus: row.current_job_status == null ? null : String(row.current_job_status) as ChatConversationItem['currentJobStatus'], + currentStatusUpdatedAt: row.current_status_updated_at == null ? null : String(row.current_status_updated_at), + runtimeActive: isRuntimeRequestActive(String(row.current_request_id ?? '')), + request: + requestMap.get(`${String(row.session_id ?? '').trim()}:${String(row.current_request_id ?? '').trim()}`) ?? null, + }), + ) + .map((row) => String(row.session_id ?? '').trim()) + .filter(Boolean); + + if (staleSessionIds.length > 0) { + const staleRequestIds = rows + .filter((row) => staleSessionIds.includes(String(row.session_id ?? '').trim())) + .map((row) => ({ + sessionId: String(row.session_id ?? '').trim(), + requestId: String(row.current_request_id ?? '').trim(), + status: String(row.current_job_status ?? '').trim(), + })) + .filter((item) => item.requestId && (item.status === 'queued' || item.status === 'started')); + + for (const staleItem of staleRequestIds) { + await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: staleItem.sessionId, + request_id: staleItem.requestId, + }) + .whereIn('status', ['queued', 'started']) + .update({ + status: 'failed', + status_message: '์ค‘๋‹จ๋œ ์˜ค๋ž˜๋œ ์š”์ฒญ', + terminal_at: db.fn.now(), + updated_at: db.fn.now(), + }); + } + + await db(CHAT_CONVERSATION_TABLE) + .whereIn('session_id', staleSessionIds) + .update({ + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); + rows = await query.clone(); + } + } + + const latestPreviewMessageMap = await getLatestPreviewableMessageMap( + rows.map((row) => String(row.session_id ?? '')), + ); + const latestRequestPreviewMap = await getLatestRequestPreviewMap( + rows.map((row) => String(row.session_id ?? '')), + ); + const latestResponseMessageIdMap = await getLatestResponseMessageIdMap( + rows.map((row) => String(row.session_id ?? '')), + ); + + if (!normalizedUnreadStateClientId) { + return rows + .map((row) => { + const mapped = mapConversationRow(row); + return { + ...resolveConversationPreviewOverride( + mapped, + latestPreviewMessageMap.get(mapped.sessionId), + latestRequestPreviewMap.get(mapped.sessionId), + ), + hasUnreadResponse: false, + }; + }) + .sort((left, right) => + (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), + ); + } + + if (rows.length === 0) { + return []; + } + + const preferences = await db(CHAT_CONVERSATION_CLIENT_TABLE) + .select('*') + .where({ client_id: normalizedUnreadStateClientId }) + .whereIn( + 'session_id', + rows.map((row) => String(row.session_id ?? '')).filter(Boolean), + ); + + const preferenceMap = new Map( + preferences.map((row) => { + const mapped = mapClientPreferenceRow(row); + return [mapped.sessionId, mapped]; + }), + ); + + return rows + .map((row) => { + const mapped = mapConversationRow(row); + const preference = preferenceMap.get(mapped.sessionId); + const latestPreviewMessage = latestPreviewMessageMap.get(mapped.sessionId); + + return { + ...resolveConversationPreviewOverride( + mapped, + latestPreviewMessage, + latestRequestPreviewMap.get(mapped.sessionId), + ), + clientId: normalizedUnreadStateClientId, + notifyOffline: preference?.notifyOffline ?? mapped.notifyOffline, + hasUnreadResponse: + (latestResponseMessageIdMap.get(mapped.sessionId) ?? 0) > + (preference?.lastReadResponseMessageId ?? 0), + }; + }) + .sort((left, right) => + (right.lastMessageAt ?? right.updatedAt).localeCompare(left.lastMessageAt ?? left.updatedAt), + ); +} + +export async function listChatConversationMessages( + sessionId: string, + options: { + limit?: number; + beforeMessageId?: number | null; + } = {}, +) { + await ensureChatConversationTables(); + const normalizedLimit = Math.max(1, Math.min(1000, Math.round(options.limit ?? 200))); + const normalizedBeforeMessageId = + Number.isFinite(options.beforeMessageId) && (options.beforeMessageId ?? 0) > 0 + ? Math.trunc(options.beforeMessageId as number) + : null; + const query = db(CHAT_CONVERSATION_MESSAGE_TABLE) + .select('*') + .where({ session_id: sessionId.trim() }) + .modify((builder) => { + if (normalizedBeforeMessageId !== null) { + builder.where('message_id', '<', normalizedBeforeMessageId); + } + }) + .orderBy('message_id', 'desc') + .orderBy('id', 'desc') + .limit(normalizedLimit); + const latestRows = await query; + + return latestRows + .reverse() + .map((row: Parameters[0]) => mapMessageRow(row)) + .filter((message: ReturnType) => isVisibleConversationMessage(message)); +} + +export async function listChatConversationRequests(sessionId: string, limit = 200) { + await ensureChatConversationTables(); + const normalizedSessionId = sessionId.trim(); + const conversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(); + + const rows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ session_id: normalizedSessionId }) + .orderBy('created_at', 'asc') + .limit(Math.max(1, Math.min(1000, Math.round(limit)))); + + return rows.map((row) => normalizeStaleRequestItem(mapRequestRow(row), conversation)); +} + +export async function getChatConversationRequest(sessionId: string, requestId: string) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId.trim(); + const normalizedRequestId = requestId.trim(); + + if (!normalizedSessionId || !normalizedRequestId) { + return null; + } + + const conversation = await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .first(); + const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + return row ? normalizeStaleRequestItem(mapRequestRow(row), conversation) : null; +} + +async function refreshConversationPreview(sessionId: string) { + const latestMessage = await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .where({ session_id: sessionId.trim() }) + .whereIn('author', ['user', 'codex']) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .first(); + + await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: sessionId.trim() }) + .update({ + last_message_preview: latestMessage && isPreviewableConversationMessage(latestMessage) ? createPreview(String(latestMessage.text ?? '')) : '', + last_message_at: latestMessage && isPreviewableConversationMessage(latestMessage) ? latestMessage.created_at : null, + updated_at: db.fn.now(), + }); +} + +export async function appendChatConversationMessage( + conversationPayload: z.input, + messagePayload: z.input, +) { + await ensureChatConversationTables(); + const conversation = conversationPayloadSchema.parse(conversationPayload); + const message = conversationMessagePayloadSchema.parse(messagePayload); + + await createChatConversation(conversation); + + const currentConversation = await db(CHAT_CONVERSATION_TABLE).where({ session_id: conversation.sessionId }).first(); + const resolvedClientRequestId = + message.clientRequestId?.trim() || + (message.author === 'codex' ? String(currentConversation?.current_request_id ?? '').trim() || null : null); + + await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .insert({ + session_id: message.sessionId, + message_id: message.messageId, + author: message.author, + text: message.text, + display_timestamp: message.timestamp, + client_request_id: resolvedClientRequestId, + created_at: db.fn.now(), + }) + .onConflict(['session_id', 'message_id']) + .merge({ + author: message.author, + text: message.text, + display_timestamp: message.timestamp, + client_request_id: resolvedClientRequestId, + }); + + const currentTitle = String(currentConversation?.title ?? '์ƒˆ ๋Œ€ํ™”').trim() || '์ƒˆ ๋Œ€ํ™”'; + const nextTitle = + message.author === 'user' && (!currentTitle || currentTitle === '์ƒˆ ๋Œ€ํ™”') + ? buildConversationTitle(message.text) + : currentTitle; + + await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: conversation.sessionId }) + .update({ + client_id: normalizeClientId(conversation.clientId) || currentConversation?.client_id || null, + title: nextTitle, + context_label: conversation.contextLabel?.trim() || currentConversation?.context_label || null, + context_description: conversation.contextDescription?.trim() || currentConversation?.context_description || null, + notify_offline: + conversation.notifyOffline == null ? Boolean(currentConversation?.notify_offline) : conversation.notifyOffline, + updated_at: db.fn.now(), + }); + + await refreshConversationPreview(conversation.sessionId); + + if (normalizeClientId(conversation.clientId) && conversation.notifyOffline != null) { + await upsertChatConversationClientPreference( + conversation.sessionId, + normalizeClientId(conversation.clientId)!, + conversation.notifyOffline, + ); + } + + const requestPatch = buildChatConversationRequestPatchFromMessage({ + id: message.messageId, + author: message.author, + text: message.text, + clientRequestId: resolvedClientRequestId, + }); + + if (requestPatch) { + await upsertChatConversationRequest(conversation.sessionId, { + requestId: requestPatch.requestId, + status: requestPatch.status, + userMessageId: requestPatch.userMessageId, + userText: requestPatch.userText, + responseMessageId: requestPatch.responseMessageId, + responseText: requestPatch.responseText, + }); + } +} + +export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) { + await ensureChatConversationTables(); + const normalizedSessionId = sessionId.trim(); + const normalizedRequestId = requestId.trim(); + const normalizedLine = line.trim(); + + if (!normalizedSessionId || !normalizedRequestId || !normalizedLine) { + return null; + } + + const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .count<{ count?: string | number }>('id as count') + .first(); + const nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1; + + await db(CHAT_CONVERSATION_ACTIVITY_TABLE) + .insert({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + line_no: nextLineNo, + text: normalizedLine, + created_at: db.fn.now(), + }) + .onConflict(['session_id', 'request_id', 'line_no']) + .ignore(); + + return nextLineNo; +} + +export async function listChatConversationActivityLogs( + sessionId: string, + limitRequests = 500, +): Promise { + await ensureChatConversationTables(); + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return []; + } + + const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .select('request_id') + .where({ session_id: normalizedSessionId }) + .whereNot('status', 'removed') + .orderBy('created_at', 'desc') + .orderBy('request_id', 'desc') + .limit(limitRequests); + const requestIds = requestRows + .map((row) => String(row.request_id ?? '').trim()) + .filter(Boolean); + + if (requestIds.length === 0) { + return []; + } + + const rows = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) + .select('session_id', 'request_id', 'text', 'line_no', 'created_at') + .where({ session_id: normalizedSessionId }) + .whereIn('request_id', requestIds) + .orderBy('request_id', 'asc') + .orderBy('line_no', 'asc') + .orderBy('id', 'asc'); + + const activityMap = new Map(); + + for (const row of rows) { + const requestId = String(row.request_id ?? '').trim(); + + if (!requestId) { + continue; + } + + const existing = activityMap.get(requestId); + + if (existing) { + existing.lines.push(String(row.text ?? '')); + existing.updatedAt = row.created_at == null ? existing.updatedAt : String(row.created_at); + continue; + } + + activityMap.set(requestId, { + sessionId: String(row.session_id ?? normalizedSessionId), + requestId, + lines: [String(row.text ?? '')], + updatedAt: row.created_at == null ? null : String(row.created_at), + }); + } + + return requestIds.map((requestId) => activityMap.get(requestId)).filter(Boolean) as ChatConversationActivityLogItem[]; +} + +export async function updateChatConversationJobState( + sessionId: string, + payload: { + requestId?: string | null; + status?: 'queued' | 'started' | 'completed' | 'failed' | null; + message?: string | null; + queueSize?: number | null; + clear?: boolean; + }, +) { + await ensureChatConversationTables(); + + const current = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); + + if (!current) { + return null; + } + + const shouldClear = payload.clear === true; + + await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: sessionId.trim() }) + .update({ + current_request_id: shouldClear ? null : payload.requestId?.trim() || current.current_request_id || null, + current_job_status: shouldClear ? null : payload.status ?? current.current_job_status ?? null, + current_job_message: shouldClear ? null : payload.message?.trim() || null, + current_queue_size: shouldClear ? 0 : Math.max(0, Math.round(payload.queueSize ?? current.current_queue_size ?? 0)), + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + const row = await db(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).first(); + return row ? mapConversationRow(row) : null; +} + +async function clearConversationJobStateForRequest(sessionId: string, requestId: string) { + await db(CHAT_CONVERSATION_TABLE) + .where({ + session_id: sessionId, + current_request_id: requestId, + }) + .update({ + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); +} + +export async function upsertChatConversationRequest( + sessionId: string, + payload: { + requestId: string; + status?: ChatConversationRequestStatus; + statusMessage?: string | null; + userMessageId?: number | null; + userText?: string | null; + responseMessageId?: number | null; + responseText?: string | null; + }, +) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId.trim(); + const normalizedRequestId = payload.requestId.trim(); + + if (!normalizedSessionId || !normalizedRequestId) { + return null; + } + + let nextRow: + | { + session_id: string; + request_id: string; + status: ChatConversationRequestStatus; + status_message: string | null; + user_message_id: number | null; + user_text: string; + response_message_id: number | null; + response_text: string; + answered_at: unknown; + terminal_at: unknown; + created_at: unknown; + updated_at: unknown; + } + | null = null; + let nextStatus: ChatConversationRequestStatus = payload.status ?? 'accepted'; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + nextStatus = mergeChatConversationRequestStatus( + (current?.status as ChatConversationRequestStatus | undefined) ?? null, + payload.status ?? null, + ); + const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus) + ? db.fn.now() + : current?.terminal_at ?? null; + const answeredAt = + payload.responseMessageId != null || (payload.responseText?.trim() ?? '').length > 0 + ? current?.answered_at ?? db.fn.now() + : current?.answered_at ?? null; + + nextRow = { + session_id: normalizedSessionId, + request_id: normalizedRequestId, + status: nextStatus, + status_message: payload.statusMessage?.trim() || current?.status_message || null, + user_message_id: payload.userMessageId ?? current?.user_message_id ?? null, + user_text: payload.userText ?? current?.user_text ?? '', + response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null, + response_text: payload.responseText ?? current?.response_text ?? '', + answered_at: answeredAt, + terminal_at: terminalStatus, + created_at: current?.created_at ?? db.fn.now(), + updated_at: db.fn.now(), + }; + + if (current) { + await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .update(nextRow); + break; + } + + const insertedRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .insert(nextRow) + .onConflict(['session_id', 'request_id']) + .ignore() + .returning(['session_id']); + + if (insertedRows.length > 0) { + break; + } + } + + if (!nextRow) { + return null; + } + + const row = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + if ( + shouldClearConversationJobState({ + currentRequestId: normalizedRequestId, + currentJobStatus: 'started', + request: { + requestId: normalizedRequestId, + status: nextStatus, + responseMessageId: nextRow.response_message_id, + responseText: nextRow.response_text, + terminalAt: nextRow.terminal_at == null ? null : String(nextRow.terminal_at), + }, + }) + ) { + await clearConversationJobStateForRequest(normalizedSessionId, normalizedRequestId); + } + + return row ? mapRequestRow(row) : null; +} + +export async function repairChatConversationRequestLinks(sessionId?: string | null) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId?.trim() || null; + const sessionRows = normalizedSessionId + ? [{ session_id: normalizedSessionId }] + : await db(CHAT_CONVERSATION_REQUEST_TABLE).distinct('session_id').orderBy('session_id', 'asc'); + + let repairedRequestCount = 0; + let linkedMessageCount = 0; + let completedStatusCount = 0; + const touchedSessions: string[] = []; + + for (const sessionRow of sessionRows) { + const currentSessionId = String(sessionRow.session_id ?? '').trim(); + + if (!currentSessionId) { + continue; + } + + const requestRows = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ session_id: currentSessionId }) + .orderBy('created_at', 'asc') + .orderBy('request_id', 'asc'); + const messageRows = await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .where({ session_id: currentSessionId }) + .select('id', 'message_id', 'author', 'text', 'client_request_id', 'created_at') + .orderBy('created_at', 'asc') + .orderBy('message_id', 'asc') + .orderBy('id', 'asc'); + + let sessionTouched = false; + const responseMessages: ChatConversationResponseCandidate[] = messageRows.map((row) => ({ + id: Number(row.id ?? 0), + messageId: Number(row.message_id ?? 0), + author: String(row.author ?? 'codex') as StoredChatMessage['author'], + text: String(row.text ?? ''), + clientRequestId: row.client_request_id == null ? null : String(row.client_request_id), + createdAt: row.created_at == null ? null : String(row.created_at), + })); + + for (let index = 0; index < requestRows.length; index += 1) { + const requestRow = requestRows[index]; + const nextRequestRow = requestRows[index + 1]; + const requestId = String(requestRow.request_id ?? '').trim(); + + if (!requestId) { + continue; + } + + const candidate = selectChatConversationResponseCandidate( + { + requestId, + createdAt: String(requestRow.created_at ?? ''), + responseMessageId: requestRow.response_message_id == null ? null : Number(requestRow.response_message_id), + }, + nextRequestRow + ? { + createdAt: String(nextRequestRow.created_at ?? ''), + } + : undefined, + responseMessages, + ); + + if (!candidate) { + continue; + } + + if ((candidate.clientRequestId?.trim() || null) !== requestId) { + await db(CHAT_CONVERSATION_MESSAGE_TABLE) + .where({ + session_id: currentSessionId, + message_id: candidate.messageId, + }) + .update({ + client_request_id: requestId, + }); + candidate.clientRequestId = requestId; + linkedMessageCount += 1; + sessionTouched = true; + } + + const shouldPromoteToCompleted = + !isTerminalRequestStatus(String(requestRow.status ?? '') as ChatConversationRequestStatus) && + requestRow.terminal_at != null; + const nextStatus = shouldPromoteToCompleted ? 'completed' : undefined; + const previousStatus = String(requestRow.status ?? '').trim(); + + await upsertChatConversationRequest(currentSessionId, { + requestId, + status: nextStatus, + responseMessageId: candidate.messageId, + responseText: candidate.text, + }); + + if ( + requestRow.response_message_id == null || + String(requestRow.response_text ?? '') !== candidate.text || + requestRow.answered_at == null + ) { + repairedRequestCount += 1; + sessionTouched = true; + } + + if (nextStatus === 'completed' && previousStatus !== 'completed') { + completedStatusCount += 1; + sessionTouched = true; + } + } + + if (sessionTouched) { + touchedSessions.push(currentSessionId); + } + } + + return { + sessionCount: sessionRows.length, + touchedSessions, + repairedRequestCount, + linkedMessageCount, + completedStatusCount, + }; +} + +export async function deleteUnansweredChatConversationRequest(sessionId: string, requestId: string) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId.trim(); + const normalizedRequestId = requestId.trim(); + const current = await db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + if (!current) { + return { deleted: false, reason: 'not_found' as const }; + } + + const conversation = await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .first(); + const mapped = normalizeStaleRequestItem(mapRequestRow(current), conversation); + + if (mapped.hasResponse) { + return { deleted: false, reason: 'answered' as const }; + } + + if (mapped.status === 'queued' || mapped.status === 'started') { + return { deleted: false, reason: 'active' as const }; + } + + await db.transaction(async (trx) => { + await trx(CHAT_CONVERSATION_MESSAGE_TABLE) + .where({ + session_id: normalizedSessionId, + client_request_id: normalizedRequestId, + }) + .del(); + + await trx(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .del(); + + const conversation = await trx(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .first(); + + if (conversation?.current_request_id === normalizedRequestId) { + await trx(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .update({ + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); + } + }); + + await refreshConversationPreview(normalizedSessionId); + return { deleted: true, reason: null as null }; +} + +export async function clearAllChatConversationJobStates() { + await ensureChatConversationTables(); + + await db(CHAT_CONVERSATION_TABLE) + .whereNotNull('current_job_status') + .update({ + current_request_id: null, + current_job_status: null, + current_job_message: null, + current_queue_size: 0, + current_status_updated_at: db.fn.now(), + updated_at: db.fn.now(), + }); +} + +export async function deleteChatConversation(sessionId: string) { + await ensureChatConversationTables(); + + return db.transaction(async (trx) => { + await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del(); + await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: sessionId.trim() }).del(); + await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: sessionId.trim() }).del(); + const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).del(); + return deletedCount > 0; + }); +} + +export async function getChatConversationClientPreference(sessionId: string, clientId: string) { + await ensureChatConversationTables(); + const row = await db(CHAT_CONVERSATION_CLIENT_TABLE) + .where({ + session_id: sessionId.trim(), + client_id: clientId.trim(), + }) + .first(); + + return row ? mapClientPreferenceRow(row) : null; +} + +export async function listChatConversationOfflineNotificationClientIds(sessionId: string) { + await ensureChatConversationTables(); + + const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) + .where({ + session_id: sessionId.trim(), + notify_offline: true, + }) + .select('client_id'); + + return rows + .map((row) => String(row.client_id ?? '').trim()) + .filter(Boolean); +} + +export async function upsertChatConversationClientPreference(sessionId: string, clientId: string, notifyOffline: boolean) { + await ensureChatConversationTables(); + const normalizedSessionId = sessionId.trim(); + const normalizedClientId = clientId.trim(); + await db(CHAT_CONVERSATION_CLIENT_TABLE) + .insert({ + session_id: normalizedSessionId, + client_id: normalizedClientId, + notify_offline: notifyOffline, + last_read_response_message_id: null, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .onConflict(['session_id', 'client_id']) + .merge({ + notify_offline: notifyOffline, + updated_at: db.fn.now(), + }); + + return getChatConversationClientPreference(normalizedSessionId, normalizedClientId); +} + +export async function markChatConversationResponsesRead(sessionId: string, clientId: string) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId.trim(); + const normalizedClientId = clientId.trim(); + + if (!normalizedSessionId || !normalizedClientId) { + return null; + } + + const currentConversation = await db(CHAT_CONVERSATION_TABLE) + .where({ session_id: normalizedSessionId }) + .first(); + + if (!currentConversation) { + return null; + } + + const latestResponseMessageId = await getLatestResponseMessageId(normalizedSessionId); + await db(CHAT_CONVERSATION_CLIENT_TABLE) + .insert({ + session_id: normalizedSessionId, + client_id: normalizedClientId, + notify_offline: Boolean(currentConversation.notify_offline), + last_read_response_message_id: latestResponseMessageId, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .onConflict(['session_id', 'client_id']) + .merge({ + last_read_response_message_id: latestResponseMessageId, + updated_at: db.fn.now(), + }); + + return { + sessionId: normalizedSessionId, + lastReadResponseMessageId: latestResponseMessageId, + }; +} diff --git a/etc/servers/work-server/src/services/chat-runtime-service.ts b/etc/servers/work-server/src/services/chat-runtime-service.ts new file mode 100755 index 0000000..08d976c --- /dev/null +++ b/etc/servers/work-server/src/services/chat-runtime-service.ts @@ -0,0 +1,392 @@ +type ChatRuntimeJobMode = 'queue' | 'direct'; +type ChatRuntimeLifecycleStatus = 'queued' | 'running'; +type ChatRuntimeTerminalStatus = 'completed' | 'failed' | 'cancelled' | 'removed'; + +type RuntimeJobControl = { + cancel?: () => Promise | boolean; + remove?: () => Promise | boolean; +}; + +type RuntimeJobRecord = ChatRuntimeJobItem & { + logs: string[]; + lastUpdatedAt: string; + terminalStatus: ChatRuntimeTerminalStatus | null; +}; + +export type ChatRuntimeJobItem = { + requestId: string; + sessionId: string; + mode: ChatRuntimeJobMode; + status: ChatRuntimeLifecycleStatus; + summary: string; + enqueuedAt: string; + startedAt: string | null; + pid: number | null; +}; + +export type ChatRuntimeSessionSummary = { + sessionId: string; + runningCount: number; + queuedCount: number; + latestRequestId: string | null; + latestStatus: ChatRuntimeLifecycleStatus | null; +}; + +export type ChatRuntimeSnapshot = { + generatedAt: string; + runningCount: number; + queuedCount: number; + sessionCount: number; + running: ChatRuntimeJobItem[]; + queued: ChatRuntimeJobItem[]; + sessions: ChatRuntimeSessionSummary[]; + recent: Array; +}; + +export type ChatRuntimeJobDetail = { + item: ChatRuntimeJobItem | null; + logs: string[]; + lastUpdatedAt: string | null; + terminalStatus: ChatRuntimeTerminalStatus | null; + availableActions: { + cancel: boolean; + remove: boolean; + }; +}; + +type RuntimeSubscriber = (snapshot: ChatRuntimeSnapshot) => void; + +const MAX_LOG_LINES = 80; +const MAX_ARCHIVED_JOBS = 40; + +function nowIso() { + return new Date().toISOString(); +} + +function summarizeText(text: string) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > 120 ? `${normalized.slice(0, 117).trimEnd()}...` : normalized; +} + +function normalizeLogLine(line: string) { + return String(line ?? '').replace(/\r/g, '').trimEnd(); +} + +class ChatRuntimeService { + private readonly queuedJobs = new Map(); + private readonly runningJobs = new Map(); + private readonly archivedJobs = new Map(); + private readonly controls = new Map(); + private readonly subscribers = new Set(); + + subscribe(listener: RuntimeSubscriber) { + this.subscribers.add(listener); + + return () => { + this.subscribers.delete(listener); + }; + } + + getSnapshot(): ChatRuntimeSnapshot { + const running = [...this.runningJobs.values()].sort((left, right) => + (left.startedAt ?? left.enqueuedAt).localeCompare(right.startedAt ?? right.enqueuedAt), + ); + const queued = [...this.queuedJobs.values()].sort((left, right) => left.enqueuedAt.localeCompare(right.enqueuedAt)); + const sessionMap = new Map(); + + for (const item of [...running, ...queued]) { + const current = sessionMap.get(item.sessionId) ?? { + sessionId: item.sessionId, + runningCount: 0, + queuedCount: 0, + latestRequestId: null, + latestStatus: null, + }; + + if (item.status === 'running') { + current.runningCount += 1; + } else { + current.queuedCount += 1; + } + + current.latestRequestId = item.requestId; + current.latestStatus = item.status; + sessionMap.set(item.sessionId, current); + } + + const sessions = [...sessionMap.values()].sort((left, right) => { + const loadDiff = right.runningCount + right.queuedCount - (left.runningCount + left.queuedCount); + return loadDiff !== 0 ? loadDiff : left.sessionId.localeCompare(right.sessionId); + }); + + return { + generatedAt: nowIso(), + runningCount: running.length, + queuedCount: queued.length, + sessionCount: sessions.length, + running: running.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item), + queued: queued.map(({ logs: _logs, lastUpdatedAt: _lastUpdatedAt, terminalStatus: _terminalStatus, ...item }) => item), + sessions, + recent: [...this.archivedJobs.values()] + .sort((left, right) => right.lastUpdatedAt.localeCompare(left.lastUpdatedAt)) + .slice(0, 12) + .map(({ logs: _logs, ...item }) => ({ + ...item, + terminalStatus: item.terminalStatus ?? 'completed', + })), + }; + } + + getJobDetail(requestId: string): ChatRuntimeJobDetail { + const current = + this.runningJobs.get(requestId) ?? + this.queuedJobs.get(requestId) ?? + this.archivedJobs.get(requestId) ?? + null; + + return { + item: current + ? { + requestId: current.requestId, + sessionId: current.sessionId, + mode: current.mode, + status: current.status, + summary: current.summary, + enqueuedAt: current.enqueuedAt, + startedAt: current.startedAt, + pid: current.pid, + } + : null, + logs: current?.logs ?? [], + lastUpdatedAt: current?.lastUpdatedAt ?? null, + terminalStatus: current?.terminalStatus ?? null, + availableActions: { + cancel: this.runningJobs.has(requestId) && this.controls.has(requestId), + remove: this.queuedJobs.has(requestId) && this.controls.has(requestId), + }, + }; + } + + registerQueuedControl(requestId: string, control: RuntimeJobControl) { + this.controls.set(requestId, control); + } + + registerRunningControl(requestId: string, control: RuntimeJobControl) { + this.controls.set(requestId, control); + } + + enqueueJob(args: { + sessionId: string; + requestId: string; + mode: ChatRuntimeJobMode; + text: string; + }) { + const existingRunning = this.runningJobs.get(args.requestId); + + if (existingRunning) { + return existingRunning; + } + + const item: RuntimeJobRecord = { + requestId: args.requestId, + sessionId: args.sessionId, + mode: args.mode, + status: 'queued', + summary: summarizeText(args.text), + enqueuedAt: nowIso(), + startedAt: null, + pid: null, + logs: ['ํ์— ๋“ฑ๋ก๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'], + lastUpdatedAt: nowIso(), + terminalStatus: null, + }; + + this.archivedJobs.delete(args.requestId); + this.queuedJobs.set(args.requestId, item); + this.emit(); + return item; + } + + startJob(args: { + sessionId: string; + requestId: string; + mode: ChatRuntimeJobMode; + text: string; + pid?: number | null; + }) { + const queuedItem = this.queuedJobs.get(args.requestId); + const runningItem: RuntimeJobRecord = { + requestId: args.requestId, + sessionId: args.sessionId, + mode: args.mode, + status: 'running', + summary: summarizeText(args.text), + enqueuedAt: queuedItem?.enqueuedAt ?? nowIso(), + startedAt: nowIso(), + pid: args.pid == null ? null : Math.round(args.pid), + logs: queuedItem?.logs ?? [], + lastUpdatedAt: nowIso(), + terminalStatus: null, + }; + + runningItem.logs = [...runningItem.logs, '์‹คํ–‰์ด ์‹œ์ž‘๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'].slice(-MAX_LOG_LINES); + + this.queuedJobs.delete(args.requestId); + this.archivedJobs.delete(args.requestId); + this.runningJobs.set(args.requestId, runningItem); + this.emit(); + return runningItem; + } + + attachProcess(requestId: string, pid?: number | null) { + const current = this.runningJobs.get(requestId); + + if (!current || pid == null) { + return current ?? null; + } + + const next: RuntimeJobRecord = { + ...current, + pid: Math.round(pid), + lastUpdatedAt: nowIso(), + logs: [...current.logs, `ํ”„๋กœ์„ธ์Šค๊ฐ€ ์—ฐ๊ฒฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. pid=${Math.round(pid)}`].slice(-MAX_LOG_LINES), + }; + + this.runningJobs.set(requestId, next); + this.emit(); + return next; + } + + appendLog(requestId: string, line: string) { + const normalizedLine = normalizeLogLine(line); + + if (!normalizedLine) { + return; + } + + const current = this.runningJobs.get(requestId) ?? this.queuedJobs.get(requestId) ?? this.archivedJobs.get(requestId); + + if (!current) { + return; + } + + const next: RuntimeJobRecord = { + ...current, + logs: [...current.logs, normalizedLine].slice(-MAX_LOG_LINES), + lastUpdatedAt: nowIso(), + }; + + if (this.runningJobs.has(requestId)) { + this.runningJobs.set(requestId, next); + } else if (this.queuedJobs.has(requestId)) { + this.queuedJobs.set(requestId, next); + } else { + this.archivedJobs.set(requestId, next); + } + + this.emit(); + } + + async cancelJob(requestId: string) { + const control = this.controls.get(requestId); + + if (!this.runningJobs.has(requestId) || !control?.cancel) { + return false; + } + + const result = await control.cancel(); + return result === true; + } + + async removeQueuedJob(requestId: string) { + const control = this.controls.get(requestId); + + if (!this.queuedJobs.has(requestId) || !control?.remove) { + return false; + } + + const result = await control.remove(); + return result === true; + } + + finishJob(requestId: string, terminalStatus: ChatRuntimeTerminalStatus = 'completed') { + const removedRunning = this.runningJobs.get(requestId); + const removedQueued = this.queuedJobs.get(requestId); + const removed = removedRunning ?? removedQueued ?? null; + + this.runningJobs.delete(requestId); + this.queuedJobs.delete(requestId); + this.controls.delete(requestId); + + if (!removed) { + return; + } + + const archived: RuntimeJobRecord = { + ...removed, + lastUpdatedAt: nowIso(), + terminalStatus, + logs: [...removed.logs, this.buildTerminalLog(terminalStatus)].slice(-MAX_LOG_LINES), + }; + + this.archivedJobs.delete(requestId); + this.archivedJobs.set(requestId, archived); + this.trimArchivedJobs(); + this.emit(); + } + + clearAll() { + if ( + this.runningJobs.size === 0 && + this.queuedJobs.size === 0 && + this.archivedJobs.size === 0 && + this.controls.size === 0 + ) { + return; + } + + this.runningJobs.clear(); + this.queuedJobs.clear(); + this.archivedJobs.clear(); + this.controls.clear(); + this.emit(); + } + + private buildTerminalLog(status: ChatRuntimeTerminalStatus) { + if (status === 'completed') { + return '์‹คํ–‰์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (status === 'failed') { + return '์‹คํ–‰์ด ์‹คํŒจ๋กœ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (status === 'cancelled') { + return '์‹คํ–‰์ด ๊ฐ•์ œ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; + } + + return '๋Œ€๊ธฐ์—ด์—์„œ ์ œ๊ฑฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'; + } + + private trimArchivedJobs() { + while (this.archivedJobs.size > MAX_ARCHIVED_JOBS) { + const firstKey = this.archivedJobs.keys().next().value; + + if (!firstKey) { + return; + } + + this.archivedJobs.delete(firstKey); + } + } + + private emit() { + const snapshot = this.getSnapshot(); + + this.subscribers.forEach((listener) => { + listener(snapshot); + }); + } +} + +export const chatRuntimeService = new ChatRuntimeService(); diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts new file mode 100644 index 0000000..4e83943 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -0,0 +1,260 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { env } from '../config/env.js'; +import { + collectOfflineNotificationClientIds, + createActivityLogMessage, + extractDiffCodeBlocks, + fitActivityLogLines, + isAutomationRegistrationCountRequest, + resolveResponseTimestamp, + rewriteCodexOutputWithChatResources, + shouldUseAgenticCodexReply, + shouldUseTemplateMacroReply, + validateAgenticCodexRuntime, +} from './chat-service.js'; + +test('collectOfflineNotificationClientIds merges session and conversation targets without duplicates', () => { + assert.deepEqual( + collectOfflineNotificationClientIds('client-a', ['client-b', ' client-a ', '', 'client-c', 'client-b']), + ['client-b', 'client-a', 'client-c'], + ); +}); + +test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => { + assert.equal(isAutomationRegistrationCountRequest('์˜ค๋Š˜ ์ž๋™ํ™” ๋“ฑ๋ก ์ด ๊ฑด์ˆ˜'), true); + assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true); + assert.equal(isAutomationRegistrationCountRequest('์ž๋™ํ™” ๋“ฑ๋ก ๊ธฐ์ค€์ด ๋ญ์•ผ'), false); +}); + +test('shouldUseAgenticCodexReply routes read and modify style requests to real Codex execution', () => { + assert.equal(shouldUseAgenticCodexReply('src/app/main/MainChatPanel.tsx ์ฝ์–ด์„œ ๊ตฌ์กฐ ์„ค๋ช…ํ•ด์ค˜'), true); + assert.equal(shouldUseAgenticCodexReply('DB ์ง์ ‘ ์กฐํšŒํ•ด์„œ ์˜ค๋Š˜ ์˜ค๋ฅ˜ ๊ฑด์ˆ˜ ํ™•์ธํ•ด์ค˜'), true); + assert.equal(shouldUseAgenticCodexReply('MainChatPanel.hotfix.css ์ˆ˜์ •ํ•ด์ค˜'), true); +}); + +test('shouldUseAgenticCodexReply keeps fast-path responses for automation registration count questions', () => { + assert.equal(shouldUseAgenticCodexReply('์˜ค๋Š˜ ์ž๋™ํ™” ๋“ฑ๋ก ์ด ๊ฑด์ˆ˜'), false); +}); + +test('shouldUseTemplateMacroReply only matches template chats and template-scoped prompts', () => { + assert.equal( + shouldUseTemplateMacroReply( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live', + chatTypeLabel: 'API ์š”์ฒญ ํ…œํ”Œ๋ฆฟ', + chatTypeDescription: 'API ์š”์ฒญ ๋ณธ๋ฌธ์„ ์ •๋ฆฌํ•˜๋Š” ํ…œํ”Œ๋ฆฟ', + chatTypeIsTemplate: true, + }, + '์ด ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ ๋ณด์—ฌ์ค˜', + ), + true, + ); + + assert.equal( + shouldUseTemplateMacroReply( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live', + chatTypeLabel: 'API ์š”์ฒญ ํ…œํ”Œ๋ฆฟ', + chatTypeDescription: 'API ์š”์ฒญ ๋ณธ๋ฌธ์„ ์ •๋ฆฌํ•˜๋Š” ํ…œํ”Œ๋ฆฟ', + chatTypeIsTemplate: true, + }, + '์•„์ดํŒจ๋“œ ๋งํ’์„  ํฐํŠธ ์กฐ๊ธˆ ์ค„์—ฌ์ค˜', + ), + false, + ); + + assert.equal( + shouldUseTemplateMacroReply( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://test.sm-home.cloud/chat/live', + chatTypeLabel: '์ผ๋ฐ˜ ์š”์ฒญ', + chatTypeDescription: '์ผ๋ฐ˜ ์š”์ฒญ', + chatTypeIsTemplate: false, + }, + 'ํ…œํ”Œ๋ฆฟ ์˜ˆ์‹œ ๋ณด์—ฌ์ค˜', + ), + false, + ); +}); + +test('fitActivityLogLines keeps modest activity history instead of trimming at 12 lines', () => { + const lines = Array.from({ length: 13 }, (_, index) => `# ์ง„ํ–‰: step ${index + 1}`); + + assert.deepEqual(fitActivityLogLines(lines), lines); +}); + +test('fitActivityLogLines keeps full activity history when it is within the configured limits', () => { + const lines = Array.from({ length: 80 }, (_, index) => `# ์ง„ํ–‰: step ${index + 1}`); + const fitted = fitActivityLogLines(lines); + + assert.equal(fitted.length, 80); + assert.equal(fitted[0], '# ์ง„ํ–‰: step 1'); + assert.equal(fitted.at(-1), '# ์ง„ํ–‰: step 80'); +}); + +test('createActivityLogMessage keeps fitted activity history instead of the latest line only', () => { + const lines = ['# ์ƒํƒœ: ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.', '# ์ง„ํ–‰: ๋ถ„์„ ์ค‘์ž…๋‹ˆ๋‹ค.', '# ์ƒํƒœ: ์‘๋‹ต ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.']; + const message = createActivityLogMessage('req-activity', lines); + + assert.ok(message); + assert.equal( + message?.text, + '[[activity-log]]\n# ์ƒํƒœ: ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.\n\n# ์ง„ํ–‰: ๋ถ„์„ ์ค‘์ž…๋‹ˆ๋‹ค.\n\n# ์ƒํƒœ: ์‘๋‹ต ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + ); +}); + +test('resolveResponseTimestamp moves fast replies behind the request second', () => { + assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 0, 250)), '2026-04-16 09:00:01'); +}); + +test('resolveResponseTimestamp keeps the real time when reply is already later', () => { + assert.equal(resolveResponseTimestamp(Date.UTC(2026, 3, 16, 0, 0, 0), Date.UTC(2026, 3, 16, 0, 0, 5)), '2026-04-16 09:00:05'); +}); + +test('extractDiffCodeBlocks collects fenced diff bodies', () => { + const output = ['์„ค๋ช…', '', '```diff', 'diff --git a/a.ts b/a.ts', '+hello', '```', '', '๋งˆ๋ฌด๋ฆฌ'].join('\n'); + + assert.deepEqual(extractDiffCodeBlocks(output), ['diff --git a/a.ts b/a.ts\n+hello']); +}); + +test('rewriteCodexOutputWithChatResources stages diff blocks as chat resources', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + await mkdir(path.join(repoPath, 'public'), { recursive: true }); + + const output = ['๋ณ€๊ฒฝ์‚ฌํ•ญ์ž…๋‹ˆ๋‹ค.', '', '```diff', 'diff --git a/src/a.ts b/src/a.ts', '+hello', '```'].join('\n'); + const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); + const expectedUrl = '/api/chat/resources/.codex_chat/chat-room/resource/_generated/response.diff'; + const savedDiffPath = path.join( + repoPath, + 'public', + '.codex_chat', + 'chat-room', + 'resource', + '_generated', + 'response.diff', + ); + + assert.match(rewritten, new RegExp(`${expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm')); + assert.equal(await readFile(savedDiffPath, 'utf8'), 'diff --git a/src/a.ts b/src/a.ts\n+hello\n'); +}); + +test('rewriteCodexOutputWithChatResources keeps existing public chat resource paths stable', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + const originalPath = path.join(repoPath, 'src', 'app', 'main', 'MainChatPanel.tsx'); + const stagedPath = path.join( + repoPath, + 'public', + '.codex_chat', + 'chat-room', + 'resource', + 'src', + 'app', + 'main', + 'MainChatPanel.tsx', + ); + + await mkdir(path.dirname(originalPath), { recursive: true }); + await mkdir(path.dirname(stagedPath), { recursive: true }); + await writeFile(originalPath, 'export const value = 1;\n', 'utf8'); + await writeFile(stagedPath, 'export const value = 1;\n', 'utf8'); + + const output = + '๋ฆฌ์†Œ์Šค ๊ฒฝ๋กœ๋Š” public/.codex_chat/chat-room/resource/src/app/main/MainChatPanel.tsx ์ž…๋‹ˆ๋‹ค.'; + const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); + + assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/src\/app\/main\/MainChatPanel\.tsx/); + assert.doesNotMatch(rewritten, /resource\/public\/\.codex_chat/); + assert.equal(await readFile(stagedPath, 'utf8'), 'export const value = 1;\n'); +}); + +test('rewriteCodexOutputWithChatResources stages repo root files linked with a leading slash', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + const originalPath = path.join(repoPath, 'docker-compose.yml'); + const stagedPath = path.join( + repoPath, + 'public', + '.codex_chat', + 'chat-room', + 'resource', + 'docker-compose.yml', + ); + + await mkdir(path.dirname(originalPath), { recursive: true }); + await writeFile(originalPath, 'services:\n app:\n image: node:22\n', 'utf8'); + + const output = 'ํŒŒ์ผ์€ /docker-compose.yml ์— ์žˆ์Šต๋‹ˆ๋‹ค.'; + const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); + + assert.match(rewritten, /\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/docker-compose\.yml/); + assert.equal(await readFile(stagedPath, 'utf8'), 'services:\n app:\n image: node:22\n'); +}); + +test('rewriteCodexOutputWithChatResources prefers absolute path replacements before nested relative paths', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-')); + const originalPath = path.join(repoPath, 'etc', 'servers', 'work-server', 'package.json'); + + await mkdir(path.dirname(originalPath), { recursive: true }); + await mkdir(path.join(repoPath, 'public'), { recursive: true }); + await writeFile(originalPath, '{\n "name": "work-server"\n}\n', 'utf8'); + + const output = + '๋ณ€๊ฒฝ ํŒŒ์ผ: [/api/chat/resources/.codex_chat/chat-room/resource/etc/servers/work-server/package.json](' + + `${originalPath})`; + const rewritten = await rewriteCodexOutputWithChatResources(output, repoPath, 'chat-room'); + + assert.match( + rewritten, + /\[\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\]\(\/api\/chat\/resources\/\.codex_chat\/chat-room\/resource\/etc\/servers\/work-server\/package\.json\)/, + ); + assert.doesNotMatch(rewritten, /\/home\/.+\/api\/chat\/resources/); +}); + +test('validateAgenticCodexRuntime explains missing runtime paths clearly', async () => { + const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL; + const originalFetch = globalThis.fetch; + env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health'; + globalThis.fetch = (async () => { + throw new Error('connect ECONNREFUSED'); + }) as typeof globalThis.fetch; + + try { + await assert.rejects( + validateAgenticCodexRuntime('/tmp/chat-missing-repo-path', '/tmp/chat-missing-codex-bin'), + /์ฑ„ํŒ… ์‹คํ–‰ ํ™˜๊ฒฝ์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค\..*PLAN_MAIN_PROJECT_REPO_PATH.*SERVER_COMMAND_RUNNER_URL/s, + ); + } finally { + env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl; + globalThis.fetch = originalFetch; + } +}); + +test('validateAgenticCodexRuntime accepts reachable command-runner api', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'chat-service-test-repo-')); + const originalRunnerUrl = env.SERVER_COMMAND_RUNNER_URL; + const originalFetch = globalThis.fetch; + env.SERVER_COMMAND_RUNNER_URL = 'http://127.0.0.1:3211/health'; + globalThis.fetch = (async () => new Response(JSON.stringify({ ok: true }), { status: 200 })) as typeof globalThis.fetch; + + try { + await assert.doesNotReject(validateAgenticCodexRuntime(repoPath, 'codex')); + } finally { + env.SERVER_COMMAND_RUNNER_URL = originalRunnerUrl; + globalThis.fetch = originalFetch; + } +}); diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts new file mode 100644 index 0000000..486f0f7 --- /dev/null +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -0,0 +1,3274 @@ +import { cp, mkdir, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import webpush from 'web-push'; +import type { FastifyBaseLogger } from 'fastify'; +import type { IncomingMessage } from 'node:http'; +import type { Socket } from 'node:net'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; +import { env } from '../config/env.js'; +import { db } from '../db/client.js'; +import { getAppConfigSnapshot } from './app-config-service.js'; +import { BOARD_POSTS_TABLE } from './board-service.js'; +import { + appendChatConversationMessage, + appendChatConversationActivityLine, + getChatConversationRequest, + getChatConversation, + listChatConversationMessages, + listChatConversationOfflineNotificationClientIds, + listChatConversationRequests, + upsertChatConversationRequest, + updateChatConversationJobState, + updateChatConversationContext, +} from './chat-room-service.js'; +import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js'; +import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; +import { createNotificationMessage } from './notification-message-service.js'; +import { + findLatestPlanItem, + findPlanItemByPreviewUrl, + findPlanItemByWorkId, + getPlanItemById, + listPlanActionHistories, + listPlanIssueHistories, + listPlanSourceWorkHistories, + mapPlanActionRow, + mapPlanIssueRow, + mapPlanSourceWorkRow, + PLAN_TABLE, +} from './plan-service.js'; + +type ChatAuthor = 'codex' | 'system' | 'user'; + +type ChatMessage = { + id: number; + author: ChatAuthor; + text: string; + timestamp: string; + clientRequestId?: string | null; +}; + +type ChatContext = { + pageId: string | null; + pageTitle: string; + topMenu: string; + focusedComponentId: string | null; + pageUrl: string; + isStandaloneMode?: boolean; + pageVisibilityState?: 'visible' | 'hidden'; + chatTypeId?: string | null; + chatTypeLabel?: string; + chatTypeDescription?: string; + chatTypeIsTemplate?: boolean; +}; + +type ChatInboundMessage = + | { + type: 'context:update'; + payload: ChatContext; + } + | { + type: 'presence:ping'; + payload?: { + at?: number; + }; + } + | { + type: 'event:received'; + payload: { + eventId: number; + }; + } + | { + type: 'message:send'; + payload: { + text: string; + requestId?: string; + mode?: 'queue' | 'direct'; + chatTypeId?: string | null; + chatTypeLabel?: string; + chatTypeDescription?: string; + chatTypeIsTemplate?: boolean; + }; + } + | { + type: 'runtime:watch'; + payload: { + requestId?: string | null; + }; + }; + +type ChatOutboundPayload = + | { + type: 'chat:init'; + payload: { + messages: ChatMessage[]; + }; + } + | { + type: 'chat:message'; + payload: ChatMessage; + } + | { + type: 'chat:message:update'; + payload: ChatMessage; + } + | { + type: 'chat:status'; + payload: { + connectedAt: string; + }; + } + | { + type: 'chat:job'; + payload: { + requestId: string; + status: 'queued' | 'started' | 'completed' | 'failed'; + mode: 'queue' | 'direct'; + queueSize: number; + message: string; + }; + } + | { + type: 'chat:error'; + payload: { + message: string; + }; + } + | { + type: 'chat:runtime'; + payload: ChatRuntimeSnapshot; + } + | { + type: 'chat:runtime:detail'; + payload: ChatRuntimeJobDetail; + } + | { + type: 'chat:activity'; + payload: { + requestId: string; + line: string; + lineCount: number; + }; + }; + +type ChatOutboundMessage = ChatOutboundPayload & { + eventId: number; + sessionId: string; +}; + +type ChatSessionState = { + sessionId: string; + clientId: string | null; + socket: WebSocket | null; + lastSeenAt: number; + context: ChatContext | null; + queue: Array<{ + requestId: string; + text: string; + mode: 'queue' | 'direct'; + requestedAtMs: number; + }>; + activeRequestCount: number; + pendingQueueReleaseEventId: number | null; + nextEventId: number; + eventHistory: ChatOutboundMessage[]; + messagePersistenceTail: Promise; + watchedRuntimeRequestId: string | null; +}; + +type ChatRuntimeController = { + getJobDetail: (requestId: string) => ReturnType; + cancelJob: (requestId: string) => Promise; + removeQueuedJob: (requestId: string) => Promise; +}; + +type ActiveChatExecution = { + cancel: () => Promise | boolean; +}; + +let activeRuntimeController: ChatRuntimeController | null = null; +const activeChatProcessRegistry = new Map(); + +export function getChatRuntimeController() { + return activeRuntimeController; +} + +const SOCKET_PATH = '/ws/chat'; +const KST_TIME_ZONE = 'Asia/Seoul'; +const STREAM_CAPTURE_LIMIT = 256 * 1024; +const CHAT_PUBLIC_RESOURCE_DIR = '.codex_chat'; +const CHAT_PUBLIC_RESOURCE_SUBDIR = 'resource'; +const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; +const SOCKET_READY_STATE_OPEN = 1; +const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; +const MAX_CHAT_ACTIVITY_MESSAGE_LINES = 240; +const MAX_CHAT_ACTIVITY_MESSAGE_CHARS = 80_000; +const CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS = 15_000; +const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12; +const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200; +const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400; +let chatMessageSequence = 0; +function hasWebPushConfig() { + return Boolean( + env.WEB_PUSH_ENABLED && + env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() && + env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() && + env.WEB_PUSH_SUBJECT?.trim(), + ); +} + +function ensureChatWebPushConfigured() { + if (!hasWebPushConfig()) { + return false; + } + + webpush.setVapidDetails( + env.WEB_PUSH_SUBJECT!, + env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim(), + env.WEB_PUSH_VAPID_PRIVATE_KEY!.trim(), + ); + + return true; +} + +function isChatSessionActivelyViewing(session: ChatSessionState) { + if (!session.socket || session.socket.readyState !== SOCKET_READY_STATE_OPEN) { + return false; + } + + if (session.context?.pageVisibilityState === 'hidden') { + return false; + } + + if (session.context?.topMenu !== 'chat') { + return false; + } + + const pageUrl = session.context?.pageUrl?.trim(); + + if (pageUrl) { + try { + const resolvedUrl = new URL(pageUrl); + + if (!resolvedUrl.pathname.startsWith('/chat/live')) { + return false; + } + } catch { + return false; + } + } + + return Date.now() - session.lastSeenAt < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS; +} + +function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) { + const fallbackUrl = new URL('https://test.sm-home.cloud/chat/live'); + fallbackUrl.searchParams.set('topMenu', 'chat'); + fallbackUrl.searchParams.set('sessionId', sessionId); + + const pageUrl = context?.pageUrl?.trim(); + + if (!pageUrl) { + return fallbackUrl.toString(); + } + + try { + const targetUrl = new URL(pageUrl); + targetUrl.searchParams.set('topMenu', targetUrl.searchParams.get('topMenu') || 'chat'); + targetUrl.searchParams.set('sessionId', sessionId); + return targetUrl.toString(); + } catch { + return fallbackUrl.toString(); + } +} + +function createChatNotificationPreview(text: string) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function createChatQuestionAnswerNotificationBody(args: { + questionText?: string | null; + answerText?: string | null; + fallback: string; +}) { + const questionPreview = createChatNotificationPreview(args.questionText ?? ''); + const answerPreview = createChatNotificationPreview(args.answerText ?? ''); + + if (questionPreview && answerPreview) { + return `์งˆ๋ฌธ: ${questionPreview}\n๋‹ต๋ณ€: ${answerPreview}`; + } + + if (answerPreview) { + return `๋‹ต๋ณ€: ${answerPreview}`; + } + + if (questionPreview) { + return `์งˆ๋ฌธ: ${questionPreview}`; + } + + return args.fallback; +} + +function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { + const questionPreview = createChatNotificationPreview(questionText ?? ''); + return questionPreview ? `์งˆ๋ฌธ: ${questionPreview}` : fallback ?? ''; +} + +function normalizeNotificationDetailText(text?: string | null) { + const normalized = String(text ?? '').trim(); + return normalized || undefined; +} + +export function collectOfflineNotificationClientIds(sessionClientId?: string | null, preferredClientIds?: string[]) { + const nextClientIds = new Set(); + + for (const candidate of preferredClientIds ?? []) { + const normalized = String(candidate ?? '').trim(); + + if (normalized) { + nextClientIds.add(normalized); + } + } + + const normalizedSessionClientId = String(sessionClientId ?? '').trim(); + + if (normalizedSessionClientId) { + nextClientIds.add(normalizedSessionClientId); + } + + return [...nextClientIds]; +} + +function isPreparingChatReply(text?: string | null) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.startsWith('์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค'); +} + +function formatTime(date: Date) { + return new Intl.DateTimeFormat('sv-SE', { + timeZone: KST_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }) + .format(date) + .replace(',', ''); +} + +export function resolveResponseTimestamp(requestedAtMs?: number | null, nowMs = Date.now()) { + if (!Number.isFinite(requestedAtMs)) { + return formatTime(new Date(nowMs)); + } + + return formatTime(new Date(Math.max(nowMs, Number(requestedAtMs) + 1_000))); +} + +function formatKstDate(date = new Date()) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: KST_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +function createChatMessageId() { + chatMessageSequence = (chatMessageSequence + 1) % 1_000; + return Date.now() * 1_000 + chatMessageSequence; +} + +function createMessage(author: ChatAuthor, text: string, clientRequestId?: string | null): ChatMessage { + return { + id: createChatMessageId(), + author, + text, + timestamp: formatTime(new Date()), + clientRequestId: clientRequestId?.trim() || null, + }; +} + +function createRequestId() { + return `chat-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function hashRequestId(value: string) { + let hash = 0; + + for (const character of value) { + hash = (hash * 31 + character.charCodeAt(0)) | 0; + } + + return Math.abs(hash) + 1_000_000; +} + +export function fitActivityLogLines(lines: string[]) { + const normalizedLines = lines.map((line) => line.trim()).filter(Boolean); + + if (normalizedLines.length <= 1) { + return normalizedLines; + } + + let startIndex = Math.max(0, normalizedLines.length - MAX_CHAT_ACTIVITY_MESSAGE_LINES); + let fittedLines = normalizedLines.slice(startIndex); + + while (fittedLines.length > 1 && fittedLines.join('\n\n').length > MAX_CHAT_ACTIVITY_MESSAGE_CHARS) { + startIndex += 1; + fittedLines = normalizedLines.slice(startIndex); + } + + return fittedLines; +} + +export function createActivityLogMessage(requestId: string, lines: string[]) { + const normalizedRequestId = requestId.trim(); + const fittedLines = fitActivityLogLines(lines); + const messageBody = fittedLines.length > 0 ? fittedLines.join('\n\n') : 'ํ™œ๋™ ๋กœ๊ทธ๋ฅผ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.'; + const messageText = `${CHAT_ACTIVITY_MESSAGE_PREFIX}\n${messageBody}`; + + return createMessage('system', messageText, normalizedRequestId) + ? { + id: hashRequestId(normalizedRequestId), + author: 'system' as const, + text: messageText, + timestamp: formatTime(new Date()), + clientRequestId: normalizedRequestId, + } + : null; +} + +function shouldPersistMessageUpdate(message: ChatMessage) { + if (message.author === 'codex') { + return false; + } + + return !String(message.text ?? '').startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); +} + +function isSocketOpen(socket: WebSocket | null | undefined) { + return Boolean(socket && socket.readyState === SOCKET_READY_STATE_OPEN); +} + +function closeSocketSafely(logger: FastifyBaseLogger, socket: WebSocket | null | undefined, message: string) { + if (!socket) { + return; + } + + try { + socket.close(); + } catch (error) { + logger.warn(error, message); + } +} + +function sendSocketEnvelope( + logger: FastifyBaseLogger, + socket: WebSocket | null | undefined, + envelope: ChatOutboundMessage, + message: string, +) { + const targetSocket = socket; + + if (!targetSocket || targetSocket.readyState !== SOCKET_READY_STATE_OPEN) { + return false; + } + + try { + targetSocket.send(JSON.stringify(envelope)); + return true; + } catch (error) { + logger.warn(error, message); + return false; + } +} + +type ParsedPlanContext = { + planId: number | null; + workId: string | null; + previewUrl: string | null; +}; + +type PlanSnapshot = { + planId: number; + workId: string; + status: string; + note: string; + workerStatus: string | null; + issueTags: string[]; + hasOpenIssues: boolean; + assignedBranch: string | null; + lastError: string | null; + latestActionNote: string | null; + latestIssue: { + tag: string; + message: string; + resolved: boolean; + } | null; + latestSourceWork: { + summary: string; + previewUrl: string | null; + changedFiles: string[]; + } | null; + recentActionNotes: string[]; + recentWorkSummaries: string[]; +}; + +function truncateText(value: string | null | undefined, limit = 120) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + return normalized.length > limit ? `${normalized.slice(0, limit - 1)}โ€ฆ` : normalized; +} + +function extractReferencedPlanId(input: string) { + const planIdMatch = input.match(/(?:planid=|#)(\d{1,9})/i) ?? input.match(/\bplan\s*(\d{1,9})\b/i); + const parsedPlanId = planIdMatch?.[1] ? Number(planIdMatch[1]) : NaN; + + return Number.isInteger(parsedPlanId) && parsedPlanId > 0 ? parsedPlanId : null; +} + +function extractReferencedWorkId(input: string) { + const workIdMatch = input.match(/\bworkid\s*[:=]?\s*([^\s,]+)/i); + return workIdMatch?.[1]?.trim() || null; +} + +function extractReferencedPreviewUrl(input: string) { + const previewUrlMatch = input.match(/https?:\/\/[^\s)]+/i); + return previewUrlMatch?.[0]?.trim() || null; +} + +function buildChangedFilesSummary(changedFiles: string[], limit = 5) { + if (changedFiles.length === 0) { + return '๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ: ๊ธฐ๋ก ์—†์Œ'; + } + + const visibleFiles = changedFiles.slice(0, limit); + const suffix = changedFiles.length > limit ? ` ์™ธ ${changedFiles.length - limit}๊ฐœ` : ''; + return `๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ: ${visibleFiles.join(', ')}${suffix}`; +} + +function parsePlanContext(context: ChatContext | null, input: string): ParsedPlanContext { + const pageUrl = context?.pageUrl ?? ''; + const referencedPlanId = extractReferencedPlanId(input); + const referencedWorkId = extractReferencedWorkId(input); + const referencedPreviewUrl = extractReferencedPreviewUrl(input); + + if (!pageUrl) { + return { + planId: referencedPlanId, + workId: referencedWorkId, + previewUrl: referencedPreviewUrl, + }; + } + + try { + const url = new URL(pageUrl); + const planIdText = url.searchParams.get('planId'); + const workIdText = url.searchParams.get('workId'); + const planIdNumber = planIdText ? Number(planIdText) : NaN; + + return { + planId: Number.isInteger(planIdNumber) && planIdNumber > 0 ? planIdNumber : referencedPlanId, + workId: workIdText?.trim() || referencedWorkId, + previewUrl: referencedPreviewUrl, + }; + } catch { + return { + planId: referencedPlanId, + workId: referencedWorkId, + previewUrl: referencedPreviewUrl, + }; + } +} + +async function loadPlanSnapshot(planId: number): Promise { + const item = await getPlanItemById(planId); + + if (!item) { + return null; + } + + const [actionRows, issueRows, sourceWorkRows] = await Promise.all([ + listPlanActionHistories(planId), + listPlanIssueHistories(planId), + listPlanSourceWorkHistories(planId), + ]); + const latestAction = actionRows[0] ? mapPlanActionRow(actionRows[0]) : null; + const latestIssue = issueRows[0] ? mapPlanIssueRow(issueRows[0]) : null; + const latestSourceWork = sourceWorkRows[0] ? mapPlanSourceWorkRow(sourceWorkRows[0]) : null; + + return { + planId, + workId: String(item.workId ?? '์ž‘์—…ID'), + status: String(item.status ?? '-'), + note: String(item.note ?? ''), + workerStatus: item.workerStatus ? String(item.workerStatus) : null, + issueTags: Array.isArray(item.issueTags) ? item.issueTags.map((value) => String(value)) : [], + hasOpenIssues: Boolean(item.hasOpenIssues), + assignedBranch: item.assignedBranch ? String(item.assignedBranch) : null, + lastError: item.lastError ? String(item.lastError) : null, + latestActionNote: latestAction ? String(latestAction.note) : null, + latestIssue: latestIssue + ? { + tag: String(latestIssue.issueTag), + message: String(latestIssue.message), + resolved: Boolean(latestIssue.resolved), + } + : null, + latestSourceWork: latestSourceWork + ? { + summary: String(latestSourceWork.summary), + previewUrl: latestSourceWork.previewUrl ? String(latestSourceWork.previewUrl) : null, + changedFiles: latestSourceWork.changedFiles, + } + : null, + recentActionNotes: actionRows + .slice(0, 5) + .map((row) => truncateText(String(mapPlanActionRow(row).note ?? ''), 140)) + .filter(Boolean), + recentWorkSummaries: sourceWorkRows + .slice(0, 5) + .map((row) => truncateText(String(mapPlanSourceWorkRow(row).summary ?? ''), 140)) + .filter(Boolean), + }; +} + +function isWorklogRequest(input: string) { + const normalized = input.toLowerCase(); + const mentionsWorklog = + input.includes('์›Œํฌ์ผ์ง€') || + input.includes('์ž‘์—…๋กœ๊ทธ') || + input.includes('์ž‘์—… ์ผ์ง€') || + input.includes('์ž‘์—…์ผ์ง€') || + normalized.includes('worklog'); + const asksToWrite = + input.includes('์ž‘์„ฑ') || + input.includes('์ •๋ฆฌ') || + input.includes('๊ธฐ๋ก') || + input.includes('๋ฐ€๋ฆฐ') || + input.includes('์จ'); + + return mentionsWorklog || (normalized.includes('log') && asksToWrite); +} + +function isPlanDetailRequest(input: string) { + const normalized = input.toLowerCase(); + + return ( + input.includes('์ž‘์—…') || + input.includes('๊ณ„ํš') || + input.includes('์ด๋ ฅ') || + input.includes('์ด์Šˆ') || + input.includes('์ƒํƒœ') || + input.includes('๋ธŒ๋žœ์น˜') || + normalized.includes('plan') || + normalized.includes('issue') || + normalized.includes('status') || + normalized.includes('history') || + normalized.includes('branch') + ); +} + +function buildWorklogReply(context: ChatContext | null, snapshot: PlanSnapshot | null) { + const pageTitle = context?.pageTitle ?? 'ํ˜„์žฌ ํ™”๋ฉด'; + + if (!snapshot) { + return `ํ˜„์žฌ ํ™”๋ฉด: ${pageTitle}\n์ž‘์—…๋กœ๊ทธ ์ดˆ์•ˆ์„ ๋งŒ๋“ค Plan์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Plan ์ƒ์„ธ๋ฅผ ์—ฐ ๋’ค ๋‹ค์‹œ ์š”์ฒญํ•ด ์ฃผ์„ธ์š”.`; + } + + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const todayWork = [ + truncateText(snapshot.note, 180), + ...snapshot.recentWorkSummaries, + snapshot.latestActionNote ? truncateText(snapshot.latestActionNote, 140) : '', + ].filter(Boolean); + const issues = snapshot.latestIssue + ? [ + `${snapshot.latestIssue.tag} ${snapshot.latestIssue.resolved ? 'ํ•ด๊ฒฐ' : '๋ฏธํ•ด๊ฒฐ'}: ${truncateText( + snapshot.latestIssue.message, + 140, + )}`, + ] + : []; + const decisions = [ + snapshot.assignedBranch ? `์ž‘์—… ๋ธŒ๋žœ์น˜: ${snapshot.assignedBranch}` : '', + snapshot.latestSourceWork?.previewUrl ? `preview ๋งํฌ: ${snapshot.latestSourceWork.previewUrl}` : '', + snapshot.status ? `ํ˜„์žฌ ์ƒํƒœ: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}` : '', + ].filter(Boolean); + const details = [...snapshot.recentActionNotes, ...snapshot.recentWorkSummaries].filter(Boolean); + + return [ + `ํ˜„์žฌ ํ™”๋ฉด: ${pageTitle}`, + `๊ธฐ์ค€ Plan: #${snapshot.planId} ${snapshot.workId}`, + '์•„๋ž˜ ์ดˆ์•ˆ์„ ์ž‘์—…์ผ์ง€์— ๋ฐ”๋กœ ๋ถ™์—ฌ ๋„ฃ์œผ๋ฉด ๋ฉ๋‹ˆ๋‹ค.', + '', + `# ${today} ์ž‘์—…์ผ์ง€`, + '', + '## ์˜ค๋Š˜ ์ž‘์—…', + ...(todayWork.length > 0 ? todayWork.slice(0, 6).map((item) => `- ${item}`) : ['- Plan ๋ฉ”๋ชจ์™€ ์ด๋ ฅ ํ™•์ธ ํ›„ ์ž‘์—… ๋‚ด์šฉ์„ ๋ณด๊ฐ•ํ•ด ์ฃผ์„ธ์š”.']), + '', + '## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ', + ...(issues.length > 0 ? issues.map((item) => `- ${item}`) : ['- ํ˜„์žฌ ๊ธฐ๋ก๋œ ์ด์Šˆ ์—†์Œ']), + '', + '## ๊ฒฐ์ • ์‚ฌํ•ญ', + ...(decisions.length > 0 ? decisions.map((item) => `- ${item}`) : ['- ๋ณ„๋„ ๊ฒฐ์ • ์‚ฌํ•ญ ์—†์Œ']), + '', + '## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ', + ...(details.length > 0 ? details.slice(0, 8).map((item) => `- ${item}`) : ['- ์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ์ด ์—†์–ด ์ƒ์„ธ ๋‚ด์—ญ ๋ณด๊ฐ•์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.']), + ].join('\n'); +} + +function buildRecentPlanHistoryLines(snapshot: PlanSnapshot | null, limit = 2) { + if (!snapshot) { + return []; + } + + const historyItems = [ + ...snapshot.recentActionNotes.map((note) => `[์กฐ์น˜] ${note}`), + ...snapshot.recentWorkSummaries.map((summary) => `[์†Œ์Šค] ${summary}`), + ] + .filter(Boolean) + .slice(0, limit); + + if (historyItems.length === 0) { + return []; + } + + return ['์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ:', ...historyItems.map((item, index) => `${index + 1}. ${item}`)]; +} + +function isDetailRequest(input: string) { + const normalized = input.toLowerCase(); + + return ( + input.includes('์ƒ์„ธ') || + input.includes('์ž์„ธํžˆ') || + input.includes('์ „์ฒด') || + normalized.includes('detail') || + normalized.includes('details') || + normalized.includes('more') + ); +} + +export function isAutomationRegistrationCountRequest(input: string) { + const normalized = input.toLowerCase(); + const mentionsAutomation = + input.includes('์ž๋™ํ™”') || normalized.includes('automation') || normalized.includes('plan'); + const mentionsRegistration = + input.includes('๋“ฑ๋ก') || input.includes('์ ‘์ˆ˜') || normalized.includes('register') || normalized.includes('count'); + const mentionsToday = + input.includes('์˜ค๋Š˜') || normalized.includes('today'); + const asksCount = + input.includes('์ด') || + input.includes('๊ฑด์ˆ˜') || + input.includes('๋ช‡ ๊ฑด') || + input.includes('๋ช‡๊ฑด') || + normalized.includes('count') || + normalized.includes('total'); + + return mentionsAutomation && mentionsRegistration && (mentionsToday || asksCount); +} + +function isAutomationRegistrationDefinitionRequest(input: string) { + const normalized = input.toLowerCase(); + const mentionsAutomation = input.includes('์ž๋™ํ™”') || normalized.includes('automation'); + const mentionsRegistration = input.includes('๋“ฑ๋ก') || input.includes('์ ‘์ˆ˜') || normalized.includes('register'); + const asksMeaning = + input.includes('๋ฌด์Šจ ๋œป') || + input.includes('๋ญ์•ผ') || + input.includes('๋œป') || + input.includes('๊ธฐ์ค€') || + input.includes('๋ญ˜ ์˜๋ฏธ') || + normalized.includes('meaning') || + normalized.includes('define'); + + return mentionsAutomation && mentionsRegistration && asksMeaning; +} + +export function shouldUseAgenticCodexReply(input: string) { + const normalized = input.toLowerCase(); + const trimmed = input.trim(); + + if (isAutomationRegistrationCountRequest(input) || isAutomationRegistrationDefinitionRequest(input)) { + return false; + } + + if (!trimmed) { + return false; + } + + if (trimmed.length <= 3 && !/(db|api|fix|bug|log|sql)/i.test(trimmed)) { + return false; + } + + if (isDetailRequest(input) || isPlanDetailRequest(input) || isWorklogRequest(input)) { + return false; + } + + return ( + input.includes('์ˆ˜์ •') || + input.includes('๋ณ€๊ฒฝ') || + input.includes('๊ตฌํ˜„') || + input.includes('์ถ”๊ฐ€') || + input.includes('์‚ญ์ œ') || + input.includes('๊ณ ์ณ') || + input.includes('๋งŒ๋“ค์–ด') || + input.includes('๋“ฑ๋กํ•ด์ค˜') || + input.includes('์กฐํšŒ') || + input.includes('ํ™•์ธ') || + input.includes('์ฝ์–ด') || + input.includes('๋ถ„์„') || + input.includes('DB') || + input.includes('API') || + input.includes('์†Œ์Šค') || + input.includes('ํŒŒ์ผ') || + input.includes('๋กœ๊ทธ') || + /(?:\/|\\).+\.[a-z0-9]+/i.test(input) || + normalized.includes('fix') || + normalized.includes('implement') || + normalized.includes('change') || + normalized.includes('edit') || + normalized.includes('read') || + normalized.includes('source') || + normalized.includes('file') || + normalized.includes('db') || + normalized.includes('api') || + /\s/.test(trimmed) + ); +} + +function extractTemplateKeywords(context: ChatContext | null) { + const raw = `${context?.chatTypeLabel ?? ''} ${context?.chatTypeDescription ?? ''}`; + + return Array.from( + new Set( + raw + .split(/[^0-9A-Za-z๊ฐ€-ํžฃ]+/) + .map((keyword) => keyword.trim()) + .filter((keyword) => keyword.length >= 2) + .filter( + (keyword) => + !['ํ…œํ”Œ๋ฆฟ', 'template', 'chat', 'codex', 'live', '์š”์ฒญ', '์ผ๋ฐ˜', '๊ธฐ๋ณธ', '์œ ํ˜•'].includes(keyword.toLowerCase()), + ), + ), + ); +} + +export function shouldUseTemplateMacroReply(context: ChatContext | null, input: string) { + if (context?.chatTypeIsTemplate !== true) { + return false; + } + + const normalized = input.toLowerCase(); + + if ( + input.includes('ํ…œํ”Œ๋ฆฟ') || + input.includes('์–‘์‹') || + input.includes('ํฌ๋งท') || + input.includes('ํ˜•์‹') || + input.includes('์˜ˆ์‹œ') || + input.includes('์ƒ˜ํ”Œ') || + normalized.includes('template') || + normalized.includes('format') || + normalized.includes('sample') + ) { + return true; + } + + return extractTemplateKeywords(context).some((keyword) => normalized.includes(keyword.toLowerCase())); +} + +function summarizeCodexOutput(output: string) { + const normalized = String(output ?? '').trim(); + + if (!normalized) { + return 'Codex ์‹คํ–‰ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'; + } + + const lines = normalized + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean); + + return lines.slice(-12).join('\n'); +} + +function summarizeCommand(command: string, limit = 180) { + const normalized = String(command ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized; +} + +function summarizeCommandOutput(output: string, maxLines = 3, maxLength = 220) { + const lines = String(output ?? '') + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length === 0) { + return ''; + } + + const joined = lines.slice(0, maxLines).join(' / '); + return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined; +} + +function inferCommandReason(command: string) { + const normalized = command.toLowerCase(); + + if (normalized.includes('rg ') || normalized.includes('ripgrep')) { + return '๊ด€๋ จ ํŒŒ์ผ์ด๋‚˜ ํ…์ŠคํŠธ๋ฅผ ๋น ๋ฅด๊ฒŒ ์ฐพ๊ธฐ ์œ„ํ•ด ๊ฒ€์ƒ‰ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) { + return 'ํ•ด๋‹น ํŒŒ์ผ ๋‚ด์šฉ์„ ์ง์ ‘ ์ฝ์–ด ๋ฌธ๋งฅ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) { + return '๋””๋ ‰ํ„ฐ๋ฆฌ ๊ตฌ์กฐ์™€ ๋Œ€์ƒ ํŒŒ์ผ ์œ„์น˜๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('git status')) { + return '์ž‘์—… ํŠธ๋ฆฌ ์ƒํƒœ์™€ ๋ณ€๊ฒฝ ๋ฒ”์œ„๋ฅผ ์ ๊ฒ€ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) { + return '์ˆ˜์ • ํ›„ ํƒ€์ž…์ด๋‚˜ ๋™์ž‘ ๊ฒ€์ฆ์„ ์ง„ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('curl ') || normalized.includes('fetch')) { + return '์‹ค์ œ ์‘๋‹ต์ด๋‚˜ ์—ฐ๊ฒฐ ์ƒํƒœ๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + return 'ํ˜„์žฌ ์š”์ฒญ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; +} + +function isIgnorableCodexDiagnosticLine(line: string) { + const normalized = line.trim(); + + if (!normalized) { + return true; + } + + return ( + /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) || + normalized.includes('ignoring interface.defaultPrompt') || + normalized.includes('failed to open state db') || + normalized.includes('state db discrepancy') || + normalized.includes('Failed to kill MCP process group') || + normalized.includes('Failed to delete shell snapshot') || + normalized.includes('failed to unwatch ') + ); +} + +function extractCodexActivityLog(parsed: Record) { + const type = typeof parsed.type === 'string' ? parsed.type : ''; + const item = parsed.item && typeof parsed.item === 'object' ? (parsed.item as Record) : null; + const itemType = typeof item?.type === 'string' ? item.type : ''; + + if (!item || itemType !== 'command_execution') { + return ''; + } + + const command = summarizeCommand(typeof item.command === 'string' ? item.command : ''); + + if (!command) { + return ''; + } + + const reason = inferCommandReason(command); + + if (type === 'item.started') { + return `# ์ด์œ : ${reason}\n$ ${command}`; + } + + if (type === 'item.completed') { + const exitCode = + typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null; + const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : ''); + const statusLabel = exitCode === null ? '# ๊ฒฐ๊ณผ: ์™„๋ฃŒ' : exitCode === 0 ? '# ๊ฒฐ๊ณผ: ์™„๋ฃŒ(0)' : `# ๊ฒฐ๊ณผ: ์ข…๋ฃŒ(${exitCode})`; + + return outputSummary ? `${statusLabel}\n# ์ถœ๋ ฅ: ${outputSummary}` : statusLabel; + } + + return ''; +} + +function normalizeCodexReplyOutput(output: string) { + const normalized = String(output ?? '').trim(); + return normalized || 'Codex ์‹คํ–‰ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'; +} + +function collectCodexTextFragments(value: unknown): string[] { + if (typeof value === 'string') { + const normalized = value.trim(); + return normalized ? [normalized] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => collectCodexTextFragments(item)); + } + + if (!value || typeof value !== 'object') { + return []; + } + + const record = value as Record; + const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message']; + + for (const key of directTextKeys) { + const fragments = collectCodexTextFragments(record[key]); + + if (fragments.length > 0) { + return fragments; + } + } + + if (typeof record.type === 'string' && record.type.includes('output_text')) { + const fragments = collectCodexTextFragments(record.text ?? record.delta); + + if (fragments.length > 0) { + return fragments; + } + } + + return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); +} + +export function extractCodexStreamText(parsed: Record) { + const type = typeof parsed.type === 'string' ? parsed.type : ''; + + if (type === 'item.completed') { + const completedText = collectCodexTextFragments(parsed.item).join(''); + return { + type, + completedText, + deltaText: '', + }; + } + + if (type === 'item.delta' || type === 'response.output_text.delta') { + const deltaText = + type === 'response.output_text.delta' + ? collectCodexTextFragments(parsed.delta ?? parsed.text).join('') + : collectCodexTextFragments(parsed.delta).join(''); + return { + type, + completedText: '', + deltaText, + }; + } + + if (type === 'response.completed') { + const completedText = collectCodexTextFragments(parsed.response).join(''); + return { + type, + completedText, + deltaText: '', + }; + } + + return { + type, + completedText: '', + deltaText: '', + }; +} + +async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) { + const normalized = normalizeCodexReplyOutput(text); + + if (!onProgress) { + return normalized; + } + + for (let index = chunkSize; index < normalized.length; index += chunkSize) { + onProgress(normalized.slice(0, index)); + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); + } + + onProgress(normalized); + return normalized; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function stripTrailingLineInfo(value: string) { + return value.replace(/:\d+(?::\d+)?$/, ''); +} + +function trimPathCandidate(value: string) { + return value.replace(/^[("'`\[]+/, '').replace(/[)\]"'`,.;:]+$/, ''); +} + +function encodeUrlPathSegments(value: string) { + return value + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); +} + +const CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN = + /^(?:src|public|docs|etc|scripts|\.github)\/[^\n\s)\]"'`,]+$/; +const CHAT_RESOURCE_ROOT_FILE_PATTERN = + /^(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)$/; + +function isLikelyRepoRelativeChatResourcePath(candidate: string) { + const normalized = candidate.replace(/^\/+/, ''); + return ( + CHAT_RESOURCE_REPO_RELATIVE_PATH_PATTERN.test(normalized) || + CHAT_RESOURCE_ROOT_FILE_PATTERN.test(normalized) + ); +} + +function buildChatResourcePublicUrl(relativePath: string) { + const cleaned = relativePath.replace(/^public\//, '').replace(/^\/+/, ''); + return `${CHAT_API_RESOURCE_ROUTE_PREFIX}/${encodeUrlPathSegments(cleaned)}`; +} + +function normalizeEmbeddedChatResourceUrls(text: string) { + return String(text ?? '').replace( + /(?:\/[^\s)\]"'`,]*?)?(\/api\/chat\/resources\/\.codex_chat\/[^\s)\]"'`,]+)/g, + (_match, resourcePath) => resourcePath, + ); +} + +function toPosixPath(value: string) { + return value.split(path.sep).join('/'); +} + +function normalizeExistingChatPublicUrl(candidate: string) { + const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())) + .replace(/^\/+/, '') + .replace(/^public\//, ''); + + if (!cleaned.startsWith(`${CHAT_PUBLIC_RESOURCE_DIR}/`)) { + return null; + } + + return buildChatResourcePublicUrl(cleaned); +} + +export function extractDiffCodeBlocks(output: string) { + const matches = Array.from(String(output ?? '').matchAll(/```diff[^\n]*\n([\s\S]*?)\n```/g)); + + return matches + .map((match) => (typeof match[1] === 'string' ? match[1].trim() : '')) + .filter(Boolean); +} + +async function resolveChatResourceSourcePath(repoPath: string, candidate: string) { + const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); + + if (!cleaned) { + return null; + } + + let sourcePath: string | null = null; + + if (cleaned.startsWith(repoPath)) { + sourcePath = cleaned; + } else if (cleaned.startsWith('/')) { + if (isLikelyRepoRelativeChatResourcePath(cleaned)) { + sourcePath = path.resolve(repoPath, cleaned.replace(/^\/+/, '')); + } else { + sourcePath = cleaned; + } + } else { + sourcePath = path.resolve(repoPath, cleaned); + } + + const normalizedRepoPath = path.resolve(repoPath); + const normalizedSourcePath = path.resolve(sourcePath); + const relativePath = path.relative(normalizedRepoPath, normalizedSourcePath); + + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return null; + } + + try { + const sourceStat = await stat(normalizedSourcePath); + + if (!sourceStat.isFile()) { + return null; + } + } catch { + return null; + } + + return { + absolutePath: normalizedSourcePath, + relativePath: toPosixPath(relativePath), + }; +} + +export function isChatResourcePathCandidate(candidate: string) { + const cleaned = stripTrailingLineInfo(trimPathCandidate(candidate.trim())); + + if (!cleaned) { + return false; + } + + if (cleaned.startsWith(`/${CHAT_PUBLIC_RESOURCE_DIR}/`)) { + return true; + } + + return isLikelyRepoRelativeChatResourcePath(cleaned) || /^\/(?:workspace|root|home|Users|tmp)\//.test(cleaned); +} + +export async function stageChatResourceFile(repoPath: string, sessionId: string, candidate: string) { + const existingPublicUrl = normalizeExistingChatPublicUrl(candidate); + + if (existingPublicUrl) { + return existingPublicUrl; + } + + const resolvedSource = await resolveChatResourceSourcePath(repoPath, candidate); + + if (!resolvedSource) { + return null; + } + + const targetRelativePath = `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/${resolvedSource.relativePath}`; + const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); + await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); + await cp(resolvedSource.absolutePath, targetAbsolutePath, { force: true }); + + return buildChatResourcePublicUrl(targetRelativePath); +} + +async function stageChatDiffResourceFiles(repoPath: string, sessionId: string, output: string) { + const diffBlocks = extractDiffCodeBlocks(output); + + if (diffBlocks.length === 0) { + return []; + } + + const urls: string[] = []; + + for (const [index, diffText] of diffBlocks.entries()) { + const fileName = diffBlocks.length === 1 ? 'response.diff' : `response-${index + 1}.diff`; + const targetRelativePath = + `${CHAT_PUBLIC_RESOURCE_DIR}/${sessionId}/${CHAT_PUBLIC_RESOURCE_SUBDIR}/_generated/${fileName}`; + const targetAbsolutePath = path.join(repoPath, 'public', targetRelativePath); + await mkdir(path.dirname(targetAbsolutePath), { recursive: true }); + await writeFile(targetAbsolutePath, `${diffText}\n`, 'utf8'); + urls.push(buildChatResourcePublicUrl(targetRelativePath)); + } + + return urls; +} + +function appendDiffResourceLinks(output: string, diffUrls: string[]) { + if (diffUrls.length === 0) { + return output; + } + + const uniqueUrls = diffUrls.filter((url, index) => diffUrls.indexOf(url) === index && !output.includes(url)); + + if (uniqueUrls.length === 0) { + return output; + } + + const lines = ['diff ๋ฆฌ์†Œ์Šค ๊ฒฝ๋กœ:']; + + if (uniqueUrls.length === 1) { + lines.push(uniqueUrls[0]!); + } else { + lines.push(...uniqueUrls.map((url, index) => `${index + 1}. ${url}`)); + } + + return `${output}\n\n${lines.join('\n')}`; +} + +export async function rewriteCodexOutputWithChatResources(output: string, repoPath: string, sessionId: string) { + const escapedRepoPath = escapeRegExp(path.resolve(repoPath)); + const filePathPattern = "[^\\n\\s)\\]\"'`,]+"; + const rootFilePattern = String.raw`\/?(?:docker-compose\.(?:yml|yaml)|[A-Za-z0-9._-]*Dockerfile(?:\.[A-Za-z0-9._-]+)?|(?:AGENTS|README)(?:\.[A-Za-z0-9._-]+)?|package(?:-lock)?\.json|tsconfig(?:\.[A-Za-z0-9._-]+)?\.json|vite\.config\.[A-Za-z0-9._-]+|eslint\.config\.[A-Za-z0-9._-]+|prettier\.config\.[A-Za-z0-9._-]+|pnpm-lock\.yaml|yarn\.lock|bun\.lockb|npm-shrinkwrap\.json|\.env(?:\.[A-Za-z0-9._-]+)?|\.gitignore|\.dockerignore)`; + const candidatePattern = new RegExp( + `${escapedRepoPath}\\/${filePathPattern}|(?:\\/?(?:public\\/)?\\.codex_chat|src|public|docs|etc|scripts)\\/${filePathPattern}|${rootFilePattern}`, + 'g', + ); + const matches = [...output.matchAll(candidatePattern)]; + + if (matches.length === 0) { + return output; + } + + const replacementMap = new Map(); + + for (const match of matches) { + const rawCandidate = match[0]?.trim(); + + if (!rawCandidate || replacementMap.has(rawCandidate)) { + continue; + } + + const stagedUrl = await stageChatResourceFile(repoPath, sessionId, rawCandidate); + + if (stagedUrl) { + replacementMap.set(rawCandidate, stagedUrl); + } + } + + let rewrittenOutput = output; + + const replacements = Array.from(replacementMap.entries()).sort((left, right) => right[0].length - left[0].length); + + for (const [sourcePath, publicUrl] of replacements) { + rewrittenOutput = rewrittenOutput.replaceAll(sourcePath, publicUrl); + } + + rewrittenOutput = normalizeEmbeddedChatResourceUrls(rewrittenOutput); + + const diffUrls = await stageChatDiffResourceFiles(repoPath, sessionId, rewrittenOutput); + return appendDiffResourceLinks(rewrittenOutput, diffUrls); +} + +function normalizeChatPromptHistoryText(text: string) { + return String(text ?? '').replace(/\s+/g, ' ').trim(); +} + +function normalizePromptHistoryMessageLimit(value: number | undefined) { + if (value === undefined || !Number.isFinite(value)) { + return CHAT_PROMPT_HISTORY_MAX_MESSAGES; + } + + return Math.min(50, Math.max(1, Math.round(value))); +} + +function normalizePromptHistoryCharLimit(value: number | undefined) { + if (value === undefined || !Number.isFinite(value)) { + return CHAT_PROMPT_HISTORY_MAX_CHARS; + } + + return Math.min(20_000, Math.max(500, Math.round(value))); +} + +async function buildRecentChatPromptHistory( + sessionId: string, + requestId: string, + limits?: { + maxMessages?: number; + maxChars?: number; + }, +) { + const maxMessages = normalizePromptHistoryMessageLimit(limits?.maxMessages); + const maxChars = normalizePromptHistoryCharLimit(limits?.maxChars); + const messages = await listChatConversationMessages(sessionId, { limit: 80 }); + const relevantMessages = messages.filter((message: (typeof messages)[number]) => { + if (message.clientRequestId?.trim() === requestId.trim()) { + return false; + } + + if (message.author !== 'user' && message.author !== 'codex') { + return false; + } + + return Boolean(normalizeChatPromptHistoryText(message.text)); + }); + + const selectedMessages: typeof relevantMessages = []; + let totalChars = 0; + + for (let index = relevantMessages.length - 1; index >= 0; index -= 1) { + const message = relevantMessages[index]; + const text = normalizeChatPromptHistoryText(message.text); + const nextChars = text.length; + + if ( + selectedMessages.length >= maxMessages || + (selectedMessages.length > 0 && totalChars + nextChars > maxChars) + ) { + break; + } + + selectedMessages.unshift(message); + totalChars += nextChars; + } + + return { + items: selectedMessages.map( + (message: (typeof selectedMessages)[number]) => `[${message.author}] ${normalizeChatPromptHistoryText(message.text)}`, + ), + omittedCount: Math.max(0, relevantMessages.length - selectedMessages.length), + }; +} + +function buildAgenticCodexPrompt( + context: ChatContext | null, + input: string, + sessionId: string, + promptContext?: { + recentHistoryLines?: string[]; + omittedHistoryCount?: number; + }, +) { + const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; + const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`; + const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; + const recentHistoryLines = promptContext?.recentHistoryLines ?? []; + const omittedHistoryCount = Math.max(0, promptContext?.omittedHistoryCount ?? 0); + const isTemplateRequest = context?.chatTypeIsTemplate === true; + + return [ + '๋‹น์‹ ์€ ์ด ์ €์žฅ์†Œ์—์„œ Codex Live ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์‹ค์ œ Codex ์‹คํ–‰๊ธฐ์ž…๋‹ˆ๋‹ค.', + `์ €์žฅ์†Œ ๋ฃจํŠธ(main_project): ${repoPath}`, + '๋ฐ˜๋“œ์‹œ ์ €์žฅ์†Œ ๋ฃจํŠธ์˜ AGENTS.md๋ฅผ ๋จผ์ € ์ฝ๊ณ  ๊ทธ ๊ทœ์น™์„ ๋”ฐ๋ฅด์„ธ์š”.', + '๊ฐ€๋Šฅํ•œ ์ž‘์—… ๋ฒ”์œ„:', + '- ๋กœ์ปฌ ์†Œ์Šค ํŒŒ์ผ ์ฝ๊ธฐ', + '- ํ•„์š” ์‹œ DB ์ง์ ‘ ์กฐํšŒ', + '- ํ•„์š” ์‹œ ๋กœ์ปฌ API ์‘๋‹ต ํ™•์ธ', + '- ์‚ฌ์šฉ์ž๊ฐ€ ์š”์ฒญํ–ˆ๊ฑฐ๋‚˜ ํ•ด๊ฒฐ์— ํ•„์š”ํ•˜๋ฉด ์†Œ์Šค ์ฝ”๋“œ ์ˆ˜์ •', + '- Codex Live์—์„œ ์†Œ์Šค ์ˆ˜์ •์ด ํ•„์š”ํ•˜๋ฉด ํ˜„์žฌ ํ”„๋กœ์ ํŠธ ํ™˜๊ฒฝ์˜ main_project ์ €์žฅ์†Œ ๋ฃจํŠธ์—์„œ ๋ฐ”๋กœ ์ˆ˜์ •ํ•˜์„ธ์š”. ๋‹จ, AGENTS.md์˜ ๋ธŒ๋žœ์น˜ ์ „๋žต์„ ๋จผ์ € ํ™•์ธํ•˜๊ณ  ๊ทธ ๊ทœ์น™ ์•ˆ์—์„œ๋งŒ ์ž‘์—…ํ•˜์„ธ์š”.', + `- ํ˜„์žฌ ์ฑ„ํŒ… ์„ธ์…˜ ๋ฆฌ์†Œ์Šค ๊ธฐ๋ณธ ๊ฒฝ๋กœ: ${chatSessionResourceDir}/`, + `- ํ˜„์žฌ ์ฑ„ํŒ… ์ฒจ๋ถ€ ์—…๋กœ๋“œ ๊ฒฝ๋กœ: ${chatSessionUploadDir}/`, + '- ์ฑ„ํŒ…์—์„œ ํŒŒ์ผ/๋ฌธ์„œ/์ด๋ฏธ์ง€/์ฝ”๋“œ ๋ฆฌ์†Œ์Šค๋ฅผ ์ œ๊ณตํ•  ๋•Œ๋Š” ๋ฐ˜๋“œ์‹œ ์œ„ ์„ธ์…˜ ์ „์šฉ ๊ฒฝ๋กœ๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•˜์„ธ์š”.', + '- ์ƒˆ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋ฌธ์„œ, ์ด๋ฏธ์ง€, ์ฝ”๋“œ ์Šค๋‹ˆํŽซ ํŒŒ์ผ, ๋กœ๊ทธ, ์‚ฐ์ถœ๋ฌผ์€ ๊ฐ€๋Šฅํ•˜๋ฉด ์ฒ˜์Œ๋ถ€ํ„ฐ ์œ„ ์„ธ์…˜ ์ „์šฉ ๊ฒฝ๋กœ ์•„๋ž˜์— ๋งŒ๋“ค๊ณ , ๋‹ค๋ฅธ ์œ„์น˜์— ๋งŒ๋“ค์—ˆ๋‹ค๋ฉด ์ตœ์ข… ์‘๋‹ต ์ „์— ์œ„ ๊ฒฝ๋กœ๋กœ ์ด๋™ํ•˜๊ฑฐ๋‚˜ ๋ณต์‚ฌํ•œ ๋’ค ๊ทธ ๊ฒฝ๋กœ๋ฅผ ์‘๋‹ตํ•˜์„ธ์š”.', + '- ์›๋ณธ ํŒŒ์ผ ๊ฒฝ๋กœ๋งŒ ์ถœ๋ ฅํ•ด๋„ ์„œ๋ฒ„๊ฐ€ ํ•ด๋‹น ์œ„์น˜๋ฅผ ์„ธ์…˜ ๋ฆฌ์†Œ์Šค๋กœ ๋ณต์‚ฌํ•ด ๋งํฌ๋กœ ๋ฐ”๊ฟ€ ์ˆ˜ ์žˆ์ง€๋งŒ, ๊ฐ€๋Šฅํ•˜๋ฉด ์„ธ์…˜ ์ „์šฉ ๊ฒฝ๋กœ ์ž์ฒด๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ•˜์„ธ์š”.', + '์‘๋‹ต ๊ทœ์น™:', + '- ์‚ฌ์‹ค์„ฑ๋ณด๋‹ค ์ถ”์ธก์„ ์šฐ์„ ํ•˜์ง€ ๋งˆ์„ธ์š”. ์˜ค๋Š˜/์ตœ์‹ /๊ฑด์ˆ˜ ์งˆ๋ฌธ์€ ์ง์ ‘ ํ™•์ธํ•˜์„ธ์š”.', + '- ์ฝ”๋“œ ์ˆ˜์ •์ด ํ•„์š” ์—†๋Š” ์งˆ๋ฌธ์ด๋ฉด ํŒŒ์ผ์„ ์ˆ˜์ •ํ•˜์ง€ ๋งˆ์„ธ์š”.', + '- ์ฝ”๋“œ ์ˆ˜์ •์„ ํ–ˆ๋‹ค๋ฉด ์ตœ์ข… ๋‹ต๋ณ€์— ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๊ธฐ๋ณธ์œผ๋กœ ```diff ์ฝ”๋“œ๋ธ”๋ก์œผ๋กœ ํฌํ•จํ•˜์„ธ์š”.', + '- ์ฝ”๋“œ ์ˆ˜์ •์ด ์žˆ์œผ๋ฉด ๋งˆ์ง€๋ง‰์— ๋ณ€๊ฒฝํ•œ ํŒŒ์ผ ๊ฒฝ๋กœ๋ฅผ ์งง๊ฒŒ ์ ์œผ์„ธ์š”.', + '- ํ•œ๊ตญ์–ด๋กœ ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๋‹ตํ•˜์„ธ์š”.', + '', + 'ํ˜„์žฌ ํ™”๋ฉด ๋ฌธ๋งฅ:', + `- pageTitle: ${context?.pageTitle ?? '์—†์Œ'}`, + `- topMenu: ${context?.topMenu ?? '์—†์Œ'}`, + `- focusedComponentId: ${context?.focusedComponentId ?? '์—†์Œ'}`, + `- pageUrl: ${context?.pageUrl ?? '์—†์Œ'}`, + `- chatTypeLabel: ${context?.chatTypeLabel ?? '์—†์Œ'}`, + `- chatTypeDescription: ${context?.chatTypeDescription ?? '์—†์Œ'}`, + `- chatTypeIsTemplate: ${isTemplateRequest ? 'true' : 'false'}`, + '', + isTemplateRequest ? 'ํ…œํ”Œ๋ฆฟ ์š”์ฒญ ๊ทœ์น™:' : '์ตœ๊ทผ ๋Œ€ํ™” ๋ฌธ๋งฅ:', + ...(isTemplateRequest + ? [ + '- ์ด ์š”์ฒญ์€ ํ…œํ”Œ๋ฆฟ ์œ ํ˜•์ž…๋‹ˆ๋‹ค.', + '- ์ด์ „ ์ฑ„ํŒ…๋ฐฉ ๋‚ด์šฉ์€ ์ฐธ์กฐํ•˜์ง€ ๋ง๊ณ , ํ˜„์žฌ ํ™”๋ฉด ๋ฌธ๋งฅ/์œ ํ˜• ์„ค๋ช…/์‚ฌ์šฉ์ž ์š”์ฒญ๋งŒ ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์„ธ์š”.', + ] + : recentHistoryLines.length > 0 + ? [ + ...recentHistoryLines.map((line) => `- ${line}`), + ...(omittedHistoryCount > 0 + ? [`- ์ตœ๊ทผ ๋ฌธ๋งฅ ์ผ๋ถ€๋งŒ ํฌํ•จํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด์ „ ${omittedHistoryCount}๊ฐœ ๋ฉ”์‹œ์ง€๋Š” ์ œ์™ธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`] + : []), + ] + : ['- ์ฐธ์กฐํ•  ์ตœ๊ทผ ๋Œ€ํ™”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.']), + '', + '์‚ฌ์šฉ์ž ์š”์ฒญ:', + input, + '', + '์ตœ์ข… ๋‹ต๋ณ€๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”.', + ].join('\n'); +} + +export async function validateAgenticCodexRuntime(repoPath: string, codexBin: string) { + const issues: string[] = []; + + try { + const repoStat = await stat(repoPath); + + if (!repoStat.isDirectory()) { + issues.push(`PLAN_MAIN_PROJECT_REPO_PATH ๊ฒฝ๋กœ๊ฐ€ ๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค: ${repoPath}`); + } + } catch { + issues.push(`PLAN_MAIN_PROJECT_REPO_PATH ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${repoPath}`); + } + + const runnerCandidates = buildCommandRunnerApiCandidates('/health'); + const headers = new Headers(); + + if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim()) { + headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); + } + + let runnerReachable = false; + + for (const candidate of runnerCandidates) { + try { + const response = await fetch(candidate, { + method: 'GET', + headers, + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + runnerReachable = true; + break; + } + } catch { + // try next candidate + } + } + + if (!runnerReachable) { + issues.push(`SERVER_COMMAND_RUNNER_URL ๊ฒฝ๋กœ๋กœ Codex runner์— ์—ฐ๊ฒฐํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${env.SERVER_COMMAND_RUNNER_URL}`); + } + + if (issues.length > 0) { + throw new Error(`์ฑ„ํŒ… ์‹คํ–‰ ํ™˜๊ฒฝ์ด ์ค€๋น„๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ${issues.join(' / ')}`); + } +} + +function normalizeRunnerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function buildCommandRunnerApiCandidates(requestPath: string) { + const configuredHealthUrl = env.SERVER_COMMAND_RUNNER_URL?.trim() || 'http://host.docker.internal:3211/health'; + + let parsedUrl: URL; + + try { + parsedUrl = new URL(configuredHealthUrl); + } catch { + return []; + } + + const hostVariants = + parsedUrl.hostname === 'host.docker.internal' + ? ['host.docker.internal', '127.0.0.1', 'localhost'] + : parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost' + ? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal'] + : [parsedUrl.hostname]; + + const deduped: string[] = []; + + for (const hostname of hostVariants) { + const candidate = new URL(parsedUrl.toString()); + candidate.hostname = hostname; + candidate.pathname = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; + candidate.search = ''; + candidate.hash = ''; + const serialized = normalizeRunnerUrl(candidate.toString()); + + if (!deduped.includes(serialized)) { + deduped.push(serialized); + } + } + + return deduped; +} + +async function requestCommandRunner(requestPath: string, init?: RequestInit) { + const headers = new Headers(init?.headers); + + if (init?.body != null && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + if (env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() && !headers.has('X-Access-Token')) { + headers.set('X-Access-Token', env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN.trim()); + } + + let lastError: Error | null = null; + + for (const url of buildCommandRunnerApiCandidates(requestPath)) { + try { + return await fetch(url, { + ...init, + headers, + }); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw lastError ?? new Error('command-runner์— ์—ฐ๊ฒฐํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); +} + +async function cancelRunnerCodexExecution(requestId: string) { + try { + const response = await requestCommandRunner(`/api/codex-live/jobs/${encodeURIComponent(requestId)}/cancel`, { + method: 'POST', + }); + + if (!response.ok) { + return false; + } + + const payload = (await response.json().catch(() => null)) as { cancelled?: boolean } | null; + return payload?.cancelled === true; + } catch { + return false; + } +} + +async function runAgenticCodexReply( + context: ChatContext | null, + input: string, + sessionId: string, + requestId: string, + onProgress?: (text: string) => void, + onActivity?: (line: string) => void, +) { + const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; + await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); + const appConfig = await getAppConfigSnapshot(); + const recentHistory = + context?.chatTypeIsTemplate === true + ? { items: [] as string[], omittedCount: 0 } + : await buildRecentChatPromptHistory(sessionId, requestId, { + maxMessages: appConfig.chat?.maxContextMessages, + maxChars: appConfig.chat?.maxContextChars, + }); + const prompt = buildAgenticCodexPrompt(context, input, sessionId, { + recentHistoryLines: recentHistory.items, + omittedHistoryCount: recentHistory.omittedCount, + }); + let streamedOutput = ''; + let stdoutTail = ''; + let stderr = ''; + let jsonLineBuffer = ''; + let lastProgressText = ''; + let completedAgentMessage = ''; + let hasIncrementalDelta = false; + activeChatProcessRegistry.set(requestId, { + cancel: () => cancelRunnerCodexExecution(requestId), + }); + + await new Promise(async (resolve, reject) => { + const emitProgress = (nextText: string) => { + const normalizedProgress = nextText.trim(); + + if (!normalizedProgress || normalizedProgress === lastProgressText) { + return; + } + + lastProgressText = normalizedProgress; + streamedOutput = normalizedProgress; + onProgress?.(normalizedProgress); + }; + + try { + const response = await requestCommandRunner('/api/codex-live/execute', { + method: 'POST', + body: JSON.stringify({ + requestId, + sessionId, + repoPath, + prompt, + resourceDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource'), + uploadDir: path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource', 'uploads'), + }), + }); + + if (!response.ok) { + reject(new Error((await response.text()) || 'command-runner Codex ์‹คํ–‰ ์š”์ฒญ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.')); + return; + } + + if (!response.body) { + reject(new Error('command-runner Codex ์ŠคํŠธ๋ฆผ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.')); + return; + } + + chatRuntimeService.appendLog(requestId, 'Codex ์‹คํ–‰์„ command-runner API๋กœ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let remoteErrorMessage = ''; + + const handleRunnerLine = (line: string) => { + let parsed: Record; + + try { + parsed = JSON.parse(line) as Record; + } catch { + return; + } + + const eventType = typeof parsed.type === 'string' ? parsed.type : ''; + + if (eventType === 'started') { + const pid = typeof parsed.pid === 'number' && Number.isFinite(parsed.pid) ? parsed.pid : null; + chatRuntimeService.attachProcess(requestId, pid); + chatRuntimeService.appendLog( + requestId, + pid ? `ํ˜ธ์ŠคํŠธ command-runner์—์„œ Codex ํ”„๋กœ์„ธ์Šค๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. pid=${pid}` : 'ํ˜ธ์ŠคํŠธ command-runner์—์„œ Codex ํ”„๋กœ์„ธ์Šค๋ฅผ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + return; + } + + if (eventType === 'activity') { + const activityLog = String(parsed.line ?? '').trim(); + + if (activityLog) { + chatRuntimeService.appendLog(requestId, activityLog); + onActivity?.(activityLog); + } + return; + } + + if (eventType === 'delta') { + const deltaText = String(parsed.text ?? ''); + + if (deltaText) { + hasIncrementalDelta = true; + emitProgress(`${streamedOutput}${deltaText}`); + } + return; + } + + if (eventType === 'completed') { + completedAgentMessage = String(parsed.text ?? '').trim(); + if (completedAgentMessage && hasIncrementalDelta) { + emitProgress(completedAgentMessage); + } + return; + } + + if (eventType === 'stdout') { + const stdoutLine = String(parsed.line ?? '').trim(); + + if (stdoutLine) { + stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); + chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); + onActivity?.(`[stdout] ${stdoutLine}`); + } + return; + } + + if (eventType === 'stderr') { + const stderrLine = String(parsed.line ?? '').trim(); + + if (stderrLine) { + stderr = `${stderr}\n${stderrLine}`.slice(-STREAM_CAPTURE_LIMIT); + chatRuntimeService.appendLog(requestId, `[stderr] ${stderrLine}`); + onActivity?.(`[stderr] ${stderrLine}`); + } + return; + } + + if (eventType === 'error') { + remoteErrorMessage = String(parsed.message ?? '').trim(); + } + }; + + while (true) { + const { value, done } = await reader.read(); + + if (done) { + break; + } + + jsonLineBuffer += decoder.decode(value, { stream: true }); + const lines = jsonLineBuffer.split('\n'); + jsonLineBuffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line) { + continue; + } + + handleRunnerLine(line); + } + } + + const trailingLine = jsonLineBuffer.trim(); + if (trailingLine) { + handleRunnerLine(trailingLine); + } + + if (remoteErrorMessage) { + reject(new Error(remoteErrorMessage)); + return; + } + + resolve(); + } catch (error) { + reject(error); + } + }); + + const normalizedOutput = normalizeCodexReplyOutput(completedAgentMessage || streamedOutput || stdoutTail); + const rewrittenOutput = await rewriteCodexOutputWithChatResources(normalizedOutput, repoPath, sessionId); + + // If the CLI only produced a final completed event, avoid sending it as one big batch. + if (!hasIncrementalDelta && rewrittenOutput) { + await streamReplyChunks(rewrittenOutput, onProgress); + } else if (rewrittenOutput !== lastProgressText) { + onProgress?.(rewrittenOutput); + } + + return rewrittenOutput; +} + +async function getTodayAutomationRegistrationCounts() { + const [planCountResult, boardReceivedCountResult] = await Promise.all([ + db(PLAN_TABLE) + .whereNot('automation_type', 'none') + .whereRaw("(created_at at time zone ?)::date = (current_timestamp at time zone ?)::date", [KST_TIME_ZONE, KST_TIME_ZONE]) + .count<{ count: string }>('* as count') + .first(), + db(BOARD_POSTS_TABLE) + .whereNotNull('automation_received_at') + .whereRaw( + "(automation_received_at at time zone ?)::date = (current_timestamp at time zone ?)::date", + [KST_TIME_ZONE, KST_TIME_ZONE], + ) + .count<{ count: string }>('* as count') + .first(), + ]); + + return { + today: formatKstDate(), + planCount: Number(planCountResult?.count ?? 0), + boardReceivedCount: Number(boardReceivedCountResult?.count ?? 0), + }; +} + +async function buildAutomationRegistrationCountReply() { + const counts = await getTodayAutomationRegistrationCounts(); + const lines = [ + '๊ฒฐ๊ณผ', + `- ${counts.today} KST ๊ธฐ์ค€ ์˜ค๋Š˜ ์ž๋™ํ™” ๋“ฑ๋ก ์ด ๊ฑด์ˆ˜๋Š” ${counts.planCount}๊ฑด์ž…๋‹ˆ๋‹ค.`, + '- ๊ธฐ๋ณธ ๊ธฐ์ค€: `plan_items`์—์„œ `automation_type <> \'none\'` ์ด๊ณ  ์˜ค๋Š˜ ์ƒ์„ฑ๋œ ๊ฑด์ˆ˜', + ]; + + if (counts.planCount === counts.boardReceivedCount) { + lines.push(`- ๊ต์ฐจ ํ™•์ธ: ์˜ค๋Š˜ ` + '`board_posts.automation_received_at`' + ` ๊ธฐ์ค€ ์ ‘์ˆ˜ ๊ฑด์ˆ˜๋„ ${counts.boardReceivedCount}๊ฑด์œผ๋กœ ๊ฐ™์Šต๋‹ˆ๋‹ค.`); + } else { + lines.push(`- ์ฐธ๊ณ : ์˜ค๋Š˜ ` + '`board_posts.automation_received_at`' + ` ๊ธฐ์ค€ ์ ‘์ˆ˜ ๊ฑด์ˆ˜๋Š” ${counts.boardReceivedCount}๊ฑด์ž…๋‹ˆ๋‹ค.`); + lines.push('- ์ด ํ”„๋กœ์ ํŠธ๋Š” ๊ฒŒ์‹œํŒ ๋“ฑ๋ก, ์ž๋™ํ™” ์ ‘์ˆ˜, Plan ์ƒ์„ฑ์ด ๋ถ„๋ฆฌ๋  ์ˆ˜ ์žˆ์–ด ๊ธฐ์ค€์— ๋”ฐ๋ผ ์ˆซ์ž๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + return lines.join('\n'); +} + +function buildAutomationRegistrationDefinitionReply() { + return [ + '๊ฒฐ๊ณผ', + '- ์ด ํ”„๋กœ์ ํŠธ์—์„œ `์ž๋™ํ™” ๋“ฑ๋ก`์€ ํ•˜๋‚˜๋กœ ๊ณ ์ •๋œ ์šฉ์–ด๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค.', + '- `board_posts.created_at`: ๊ฒŒ์‹œํŒ ๋“ฑ๋ก', + '- `board_posts.automation_received_at`: ์ž๋™ํ™” ์ ‘์ˆ˜', + '- `plan_items.created_at` + `automation_type <> \'none\'`: ์‹ค์ œ ์ž๋™ํ™” ๋Œ€์ƒ Plan ์ƒ์„ฑ', + '- ๊ฑด์ˆ˜ ์งˆ๋ฌธ์ด๋ฉด ์–ด๋–ค ๊ธฐ์ค€์ธ์ง€ ๋จผ์ € ๊ตฌ๋ถ„ํ•˜๊ณ , ๋ชจํ˜ธํ•˜๋ฉด ๊ธฐ๋ณธ์ ์œผ๋กœ Plan ์ƒ์„ฑ ๊ธฐ์ค€์„ ์šฐ์„  ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค.', + ].join('\n'); +} + +function buildProgressMessages(input: string) { + const messages = ['์ƒ๊ฐ ์ค‘์ž…๋‹ˆ๋‹ค. ์š”์ฒญ ์˜๋„์™€ ํ˜„์žฌ ํ™”๋ฉด ๋ฌธ๋งฅ์„ ๋จผ์ € ์ •๋ฆฌํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.']; + + if (isAutomationRegistrationCountRequest(input)) { + messages.push('์˜ค๋Š˜ ๊ธฐ์ค€ ์ง‘๊ณ„๊ฐ€ ํ•„์š”ํ•œ ์š”์ฒญ์ด๋ผ DB ๊ธฐ์ค€๊ณผ ์‹œ๊ฐ„๋Œ€ ๊ธฐ์ค€์„ ํ™•์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.'); + return messages; + } + + if (/db|๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค|sql|์ฟผ๋ฆฌ|์ง‘๊ณ„|๊ฑด์ˆ˜/i.test(input)) { + messages.push('DB๋ฅผ ์ง์ ‘ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š”์ง€์™€ ์ง‘๊ณ„ ๊ธฐ์ค€์„ ํ•จ๊ป˜ ๋ณด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + if (/api|์‘๋‹ต|endpoint|์—”๋“œํฌ์ธํŠธ|fetch|ํ˜ธ์ถœ/i.test(input)) { + messages.push('๊ด€๋ จ API ๊ฒฝ๋กœ์™€ ์‹ค์ œ ์‘๋‹ต ๊ธฐ์ค€์„ ํ™•์ธํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + if (/ํŒŒ์ผ|์†Œ์Šค|์ฝ”๋“œ|tsx|ts|js|css|์ˆ˜์ •|๋ณ€๊ฒฝ|๊ตฌํ˜„|fix|edit|implement/i.test(input)) { + messages.push('๊ด€๋ จ ์†Œ์Šค ํŒŒ์ผ๊ณผ ์—ฐ๊ฒฐ๋œ ํ๋ฆ„์„ ์ฝ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + messages.push('ํ•„์š”ํ•˜๋ฉด ์‹ค์ œ Codex ์‹คํ–‰๊ธฐ๋กœ ์ด์–ด์„œ ์กฐํšŒํ•˜๊ฑฐ๋‚˜ ์ˆ˜์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.'); + return [...new Set(messages)]; +} + +function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { + const normalized = input.toLowerCase(); + const pageTitle = context?.pageTitle ?? 'ํ˜„์žฌ ํ™”๋ฉด'; + const previewUrl = context?.pageUrl || '์—†์Œ'; + const detailRequested = isDetailRequest(input); + + if (normalized.includes('preview') || normalized.includes('๋งํฌ') || normalized.includes('url')) { + const lines = [ + '๊ฒฐ๊ณผ', + `- preview: ${snapshot?.latestSourceWork?.previewUrl ?? previewUrl}`, + ]; + + if (snapshot?.latestSourceWork?.summary) { + lines.push(`- ์š”์•ฝ: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); + } + + if (detailRequested && snapshot?.latestSourceWork) { + lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); + } + + if (!detailRequested) { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); + } + + if (input.includes('๊ณ„ํš') || normalized.includes('plan')) { + if (!snapshot) { + return ['๊ฒฐ๊ณผ', '- ์—ฐ๊ฒฐ๋œ Plan์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', '- planId ๋˜๋Š” ์ž‘์—… ID๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด ์ฃผ์„ธ์š”.'].join('\n'); + } + + const lines = [ + '์ž‘์—… ์š”์•ฝ', + `- #${snapshot.planId} ${snapshot.workId}`, + `- ์ƒํƒœ: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, + `- ๋ฉ”๋ชจ: ${truncateText(snapshot.note, 110) || '๊ธฐ๋ก ์—†์Œ'}`, + ]; + + if (detailRequested) { + if (snapshot.assignedBranch) { + lines.push(`- ๋ธŒ๋žœ์น˜: ${snapshot.assignedBranch}`); + } + + lines.push(...buildRecentPlanHistoryLines(snapshot)); + } else { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); + } + + if ((input.includes('์ด์Šˆ') || normalized.includes('issue')) && snapshot) { + const lines = ['์ด์Šˆ ์š”์•ฝ']; + + if (snapshot.latestIssue) { + lines.push( + `- ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? 'ํ•ด๊ฒฐ' : '๋ฏธํ•ด๊ฒฐ'}`, + `- ๋‚ด์šฉ: ${truncateText(snapshot.latestIssue.message, 110)}`, + ); + } else if (snapshot.issueTags.length > 0) { + lines.push(`- ํƒœ๊ทธ: ${snapshot.issueTags.join(', ')}`); + } else { + lines.push('- ์ตœ๊ทผ ์ด์Šˆ ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + } + + if (detailRequested) { + lines.push(...buildRecentPlanHistoryLines(snapshot)); + } + + return lines.join('\n'); + } + + if ((input.includes('์ด๋ ฅ') || input.includes('์ตœ๊ทผ') || normalized.includes('history')) && snapshot) { + const lines = [ + '์ตœ๊ทผ ์ด๋ ฅ', + `- ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 120) || '์ตœ๊ทผ ์ž‘์—… ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.'}`, + ]; + + if (detailRequested) { + lines.push(...buildRecentPlanHistoryLines(snapshot)); + } else { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); + } + + if (input.includes('๋ฌธ์„œ') || normalized.includes('docs')) { + return ['๊ฒฐ๊ณผ', '- ๋ฌธ์„œ ์š”์ฒญ์œผ๋กœ ์ธ์‹ํ–ˆ์Šต๋‹ˆ๋‹ค.', '- ๋Œ€์ƒ ๋ฌธ์„œ๋ช…์ด๋‚˜ ๋งํฌ๋ฅผ ํ•จ๊ป˜ ๋ณด๋‚ด ์ฃผ์„ธ์š”.'].join('\n'); + } + + if (isWorklogRequest(input)) { + if (snapshot) { + return buildWorklogReply(context, snapshot); + } + + return ['๊ฒฐ๊ณผ', '- ์ž‘์—…๋กœ๊ทธ ์ดˆ์•ˆ์„ ๋งŒ๋“ค Plan์ด ์—†์Šต๋‹ˆ๋‹ค.', '- planId ๋˜๋Š” ์ž‘์—… ํ™”๋ฉด์—์„œ ๋‹ค์‹œ ์š”์ฒญํ•ด ์ฃผ์„ธ์š”.'].join('\n'); + } + + if ( + input.includes('์ปดํฌ๋„ŒํŠธ') || + normalized.includes('widget') || + normalized.includes('api') + ) { + return ['๊ฒฐ๊ณผ', '- ๋Œ€์ƒ ๋ฆฌ์†Œ์Šค๋ฅผ ํŠน์ •ํ•ด ์ฃผ์„ธ์š”.', '- ์˜ˆ: ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ, previewer, history api'].join('\n'); + } + + if (snapshot && isPlanDetailRequest(input)) { + const lines = [ + '์ž‘์—… ์š”์•ฝ', + `- #${snapshot.planId} ${snapshot.workId}`, + `- ์ƒํƒœ: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`, + `- ์ตœ๊ทผ ์ž‘์—…: ${truncateText(snapshot.latestSourceWork?.summary ?? snapshot.latestActionNote ?? snapshot.note, 110) || '๊ธฐ๋ก ์—†์Œ'}`, + ]; + + if (detailRequested) { + lines.push(...buildRecentPlanHistoryLines(snapshot)); + } else { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); + } + + return [ + '๊ฒฐ๊ณผ', + `- ํ˜„์žฌ ํ™”๋ฉด: ${pageTitle}`, + '- ์š”์ฒญ์„ ๋” ๊ตฌ์ฒด์ ์œผ๋กœ ์ ์–ด ์ฃผ์‹œ๋ฉด ํ•„์š”ํ•œ ์ •๋ณด๋งŒ ์งง๊ฒŒ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.', + '- ์˜ˆ: preview ๋งํฌ, ์ตœ๊ทผ ์ด์Šˆ, ์ž‘์—… ์š”์•ฝ, ์ƒ์„ธ', + ].join('\n'); +} + +function buildPlanReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { + const normalized = input.toLowerCase(); + const detailRequested = isDetailRequest(input); + const pageTitle = context?.pageTitle ?? 'ํ˜„์žฌ ํ™”๋ฉด'; + + if (!snapshot) { + return `ํ˜„์žฌ ํ™”๋ฉด: ${pageTitle}\n์„ ํƒ๋œ Plan์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Plan ๋ชฉ๋ก์—์„œ ํ•ญ๋ชฉ์„ ์„ ํƒํ•œ ๋’ค ๋‹ค์‹œ ์š”์ฒญํ•ด ์ฃผ์„ธ์š”.`; + } + + const lines = ['์ž‘์—… ์š”์•ฝ', `- #${snapshot.planId} ${snapshot.workId}`, `- ์ƒํƒœ: ${snapshot.status}${snapshot.workerStatus ? ` / ${snapshot.workerStatus}` : ''}`]; + + if (isWorklogRequest(input)) { + return buildWorklogReply(context, snapshot); + } + + if (snapshot.note) { + lines.push(`- ๋ฉ”๋ชจ: ${truncateText(snapshot.note, 110)}`); + } + + if (normalized.includes('preview') || normalized.includes('๋งํฌ') || normalized.includes('url')) { + lines.push(`- preview: ${snapshot.latestSourceWork?.previewUrl ?? context?.pageUrl ?? '์—†์Œ'}`); + if (snapshot.latestSourceWork) { + lines.push(`- ์ตœ๊ทผ ์ž‘์—…: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); + if (detailRequested) { + lines.push(`- ${buildChangedFilesSummary(snapshot.latestSourceWork.changedFiles)}`); + } + } + + if (!detailRequested) { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); + } + + if (snapshot.latestIssue) { + lines.push(`- ์ตœ๊ทผ ์ด์Šˆ: ${snapshot.latestIssue.tag} / ${snapshot.latestIssue.resolved ? 'ํ•ด๊ฒฐ' : '๋ฏธํ•ด๊ฒฐ'}`); + if (detailRequested) { + lines.push(`- ์ด์Šˆ ๋‚ด์šฉ: ${truncateText(snapshot.latestIssue.message, 100)}`); + } + } else if (snapshot.issueTags.length > 0) { + lines.push(`- ์ด์Šˆ ํƒœ๊ทธ: ${snapshot.issueTags.join(', ')}`); + } + + if (snapshot.latestActionNote) { + lines.push(`- ์ตœ๊ทผ ์กฐ์น˜: ${truncateText(snapshot.latestActionNote, 100)}`); + } + + if (snapshot.latestSourceWork) { + lines.push(`- ์ตœ๊ทผ ์ž‘์—…: ${truncateText(snapshot.latestSourceWork.summary, 100)}`); + if (detailRequested && snapshot.latestSourceWork.changedFiles.length > 0) { + lines.push(`- ํŒŒ์ผ ${snapshot.latestSourceWork.changedFiles.length}๊ฐœ ๋ณ€๊ฒฝ`); + } + } + + if (snapshot.lastError && snapshot.hasOpenIssues) { + lines.push(`- ์ฃผ์˜: ${truncateText(snapshot.lastError, 100)}`); + } + + if (detailRequested) { + if (snapshot.assignedBranch) { + lines.push(`- ๋ธŒ๋žœ์น˜: ${snapshot.assignedBranch}`); + } + lines.push(...buildRecentPlanHistoryLines(snapshot)); + } else { + lines.push('- ์ƒ์„ธ๊ฐ€ ํ•„์š”ํ•˜๋ฉด "์ƒ์„ธ"๋ผ๊ณ  ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.'); + } + + return lines.join('\n'); +} + +async function buildCodexReply( + context: ChatContext | null, + input: string, + sessionId: string, + requestId: string, + onProgress?: (text: string) => void, + onActivity?: (line: string) => void, +) { + const normalized = input.toLowerCase(); + + if (isAutomationRegistrationCountRequest(input)) { + return buildAutomationRegistrationCountReply(); + } + + if (isAutomationRegistrationDefinitionRequest(input)) { + return buildAutomationRegistrationDefinitionReply(); + } + + if (shouldUseAgenticCodexReply(input)) { + return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); + } + + const requestsPlanContext = + isWorklogRequest(input) || + isPlanDetailRequest(input) || + normalized.includes('preview') || + normalized.includes('๋งํฌ') || + normalized.includes('url') || + input.includes('๋ณ€๊ฒฝ') || + input.includes('์Šคํฌ๋ฆฐ์ƒท'); + const parsedPlanContext = parsePlanContext(context, input); + let snapshot = parsedPlanContext.planId ? await loadPlanSnapshot(parsedPlanContext.planId) : null; + + if (!snapshot && parsedPlanContext.workId) { + const planItem = await findPlanItemByWorkId(parsedPlanContext.workId); + snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null; + } + + if (!snapshot && parsedPlanContext.previewUrl) { + const planItem = await findPlanItemByPreviewUrl(parsedPlanContext.previewUrl); + snapshot = planItem?.id ? await loadPlanSnapshot(Number(planItem.id)) : null; + } + + if (!snapshot && requestsPlanContext) { + const latestPlanItem = await findLatestPlanItem(); + snapshot = latestPlanItem?.id ? await loadPlanSnapshot(Number(latestPlanItem.id)) : null; + } + + const isPlanPage = + context?.topMenu === 'plans' || + context?.pageId?.startsWith('plans:') || + isPlanDetailRequest(input); + + if (isPlanPage && snapshot) { + return buildPlanReply(context, input, snapshot); + } + + if (!shouldUseTemplateMacroReply(context, input)) { + return runAgenticCodexReply(context, input, sessionId, requestId, onProgress, onActivity); + } + + return buildGenericReply(context, input, snapshot); +} + +export class ChatService { + private readonly wss = new WebSocketServer({ noServer: true }); + + private readonly clientStates = new Map(); + private readonly sessions = new Map(); + private readonly cancelledRequestIds = new Set(); + private readonly unsubscribeRuntimeBroadcast: () => void; + + constructor(private readonly logger: FastifyBaseLogger) { + activeRuntimeController = { + getJobDetail: (requestId) => chatRuntimeService.getJobDetail(requestId), + cancelJob: (requestId) => this.cancelRuntimeJob(requestId), + removeQueuedJob: (requestId) => this.removeQueuedRuntimeJob(requestId), + }; + + this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => { + this.broadcastRuntimeSnapshot(snapshot); + }); + + this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => { + void this.handleConnection(socket, request).catch((error: unknown) => { + const session = this.clientStates.get(socket); + this.logger.error(error, 'chat websocket connection initialization failed'); + this.clientStates.delete(socket); + if (session?.socket === socket) { + session.socket = null; + } + closeSocketSafely(this.logger, socket, 'failed to close websocket after initialization error'); + }); + }); + } + + attachUpgradeHandler() { + return (request: IncomingMessage, socket: Socket, head: Buffer) => { + const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; + const url = new URL(request.url ?? '/', origin); + + if (url.pathname !== SOCKET_PATH) { + socket.destroy(); + return; + } + + this.wss.handleUpgrade(request, socket, head, (websocket: WebSocket) => { + this.wss.emit('connection', websocket, request); + }); + }; + } + + close() { + activeRuntimeController = null; + this.unsubscribeRuntimeBroadcast(); + + for (const execution of activeChatProcessRegistry.values()) { + void Promise.resolve(execution.cancel()).catch(() => { + // noop + }); + } + + activeChatProcessRegistry.clear(); + chatRuntimeService.clearAll(); + + for (const client of this.clientStates.keys()) { + client.close(); + } + + this.wss.close(); + } + + private getOrCreateSession(sessionId: string, clientId?: string | null) { + const existing = this.sessions.get(sessionId); + + if (existing) { + if (clientId?.trim()) { + existing.clientId = clientId.trim(); + } + return existing; + } + + const nextSession: ChatSessionState = { + sessionId, + clientId: clientId?.trim() || null, + socket: null, + lastSeenAt: Date.now(), + context: null, + queue: [], + activeRequestCount: 0, + pendingQueueReleaseEventId: null, + nextEventId: 1, + eventHistory: [], + messagePersistenceTail: Promise.resolve(), + watchedRuntimeRequestId: null, + }; + this.sessions.set(sessionId, nextSession); + return nextSession; + } + + private createSessionEnvelope(session: ChatSessionState, message: ChatOutboundPayload): ChatOutboundMessage { + return { + ...message, + eventId: session.nextEventId++, + sessionId: session.sessionId, + }; + } + + private retainEnvelopeForReplay(session: ChatSessionState, envelope: ChatOutboundMessage) { + if (envelope.type === 'chat:init' || envelope.type === 'chat:status') { + return; + } + + session.eventHistory.push(envelope); + + if (session.eventHistory.length > CHAT_SESSION_EVENT_HISTORY_LIMIT) { + session.eventHistory.splice(0, session.eventHistory.length - CHAT_SESSION_EVENT_HISTORY_LIMIT); + } + } + + private persistConversationMessage(session: ChatSessionState, message: ChatMessage) { + const nextPersistence = session.messagePersistenceTail + .catch(() => undefined) + .then(() => + appendChatConversationMessage( + { + sessionId: session.sessionId, + clientId: session.clientId, + title: null, + contextLabel: session.context?.chatTypeLabel ?? null, + contextDescription: session.context?.chatTypeDescription ?? null, + notifyOffline: undefined, + }, + { + sessionId: session.sessionId, + messageId: message.id, + author: message.author, + text: message.text, + timestamp: message.timestamp, + clientRequestId: message.clientRequestId ?? null, + }, + ), + ); + + session.messagePersistenceTail = nextPersistence.catch((error: unknown) => { + this.logger.error(error, 'failed to persist chat message'); + }); + + return session.messagePersistenceTail; + } + + private sendToSession( + session: ChatSessionState, + message: ChatOutboundPayload, + options?: { + skipOfflineNotification?: boolean; + }, + ) { + const envelope = this.createSessionEnvelope(session, message); + this.retainEnvelopeForReplay(session, envelope); + + sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket session envelope'); + + if (message.type === 'chat:message') { + this.persistConversationMessage(session, message.payload); + + if (message.payload.author === 'codex' && options?.skipOfflineNotification !== true) { + void this.sendOfflineNotificationIfNeeded(session, message.payload).catch((error: unknown) => { + this.logger.error(error, 'failed to send offline chat notification'); + }); + } + } + + return envelope; + } + + private updateMessageInSession(session: ChatSessionState, message: ChatMessage) { + const envelope = this.createSessionEnvelope(session, { + type: 'chat:message:update', + payload: message, + }); + this.retainEnvelopeForReplay(session, envelope); + + sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send websocket message update envelope'); + + // Streaming codex deltas and synthesized activity summaries are transient UI state. + // Persist only the final chat message / activity rows to avoid long DB tails that + // can keep a finished request looking "running" until every intermediate update flushes. + if (shouldPersistMessageUpdate(message)) { + this.persistConversationMessage(session, message); + } + + return envelope; + } + + private broadcastRuntimeSnapshot(snapshot = chatRuntimeService.getSnapshot()) { + for (const session of this.sessions.values()) { + this.sendToSession(session, { + type: 'chat:runtime', + payload: snapshot, + }); + + this.pushRuntimeDetail(session); + } + } + + private pushRuntimeDetail(session: ChatSessionState) { + const requestId = session.watchedRuntimeRequestId?.trim(); + + if (!requestId) { + return; + } + + this.sendToSession(session, { + type: 'chat:runtime:detail', + payload: chatRuntimeService.getJobDetail(requestId), + }); + } + + private async cancelRuntimeJob(requestId: string) { + const execution = activeChatProcessRegistry.get(requestId); + + if (!execution) { + return false; + } + + chatRuntimeService.appendLog(requestId, '์‚ฌ์šฉ์ž ์š”์ฒญ์œผ๋กœ ์‹คํ–‰ ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.'); + this.cancelledRequestIds.add(requestId); + const session = this.findSessionByRequestId(requestId); + + if (session) { + void upsertChatConversationRequest(session.sessionId, { + requestId, + status: 'cancelled', + statusMessage: '์‚ฌ์šฉ์ž ์š”์ฒญ์œผ๋กœ ์‹คํ–‰ ์ทจ์†Œ๋ฅผ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค.', + }).catch((error: unknown) => { + this.logger.warn(error, 'failed to persist chat request cancellation state'); + }); + } + + try { + return await execution.cancel(); + } catch (error) { + this.logger.warn(error, 'failed to cancel chat runtime job'); + chatRuntimeService.appendLog(requestId, '์‹คํ–‰ ์ทจ์†Œ ์š”์ฒญ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + this.cancelledRequestIds.delete(requestId); + return false; + } + } + + private async removeQueuedRuntimeJob(requestId: string) { + let removed = false; + let removedSessionId: string | null = null; + + for (const session of this.sessions.values()) { + const nextQueue = session.queue.filter((item) => item.requestId !== requestId); + + if (nextQueue.length === session.queue.length) { + continue; + } + + session.queue = nextQueue; + removed = true; + removedSessionId = session.sessionId; + this.emitJobState(session, { + requestId, + status: 'completed', + mode: 'queue', + queueSize: session.queue.length, + message: '๋Œ€๊ธฐ์—ด์—์„œ ์ œ๊ฑฐ๋จ', + }); + this.sendToSession(session, { + type: 'chat:message', + payload: createMessage('system', '๋Œ€๊ธฐ์—ด ์š”์ฒญ์ด ๊ด€๋ฆฌ ํ™”๋ฉด์—์„œ ์ œ๊ฑฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + }); + break; + } + + if (!removed) { + return false; + } + + chatRuntimeService.finishJob(requestId, 'removed'); + if (removedSessionId) { + await upsertChatConversationRequest(removedSessionId, { + requestId, + status: 'removed', + statusMessage: '๋Œ€๊ธฐ์—ด์—์„œ ์ œ๊ฑฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + return true; + } + + private tryStartNextQueuedRequest(session: ChatSessionState) { + this.normalizeSessionExecutionState(session); + + if (session.activeRequestCount > 0 || session.queue.length === 0 || session.pendingQueueReleaseEventId !== null) { + return; + } + + const nextQueuedRequest = session.queue.shift(); + + if (!nextQueuedRequest) { + return; + } + + void this.executeRequest(session, nextQueuedRequest).catch((error: unknown) => { + this.logger.error(error, 'queued chat reply build failed'); + this.sendToSession(session, { + type: 'chat:error', + payload: { + message: '๋Œ€๊ธฐ ์ค‘์ด๋˜ ์ฑ„ํŒ… ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + }, + }); + }); + } + + private normalizeSessionExecutionState(session: ChatSessionState) { + const snapshot = chatRuntimeService.getSnapshot(); + const runtimeSession = snapshot.sessions.find((item) => item.sessionId === session.sessionId) ?? null; + const runtimeRunningCount = runtimeSession?.runningCount ?? 0; + const runtimeQueuedIds = new Set( + snapshot.queued.filter((item) => item.sessionId === session.sessionId).map((item) => item.requestId), + ); + + session.activeRequestCount = runtimeRunningCount; + session.queue = session.queue.filter((item) => runtimeQueuedIds.has(item.requestId)); + + if (runtimeRunningCount === 0) { + session.pendingQueueReleaseEventId = null; + } + } + + private emitJobState( + session: ChatSessionState, + payload: { + requestId: string; + status: 'queued' | 'started' | 'completed' | 'failed'; + mode: 'queue' | 'direct'; + queueSize: number; + message: string; + }, + ) { + this.sendToSession(session, { + type: 'chat:job', + payload, + }); + + void updateChatConversationJobState(session.sessionId, { + requestId: payload.requestId, + status: payload.status, + message: payload.message, + queueSize: payload.queueSize, + clear: false, + }).catch((error: unknown) => { + this.logger.error(error, 'failed to persist chat job state'); + }); + + const requestStatus = + payload.status === 'queued' + ? 'queued' + : payload.status === 'started' + ? 'started' + : payload.status === 'completed' + ? 'completed' + : this.cancelledRequestIds.has(payload.requestId) + ? 'cancelled' + : 'failed'; + + void upsertChatConversationRequest(session.sessionId, { + requestId: payload.requestId, + status: requestStatus, + statusMessage: payload.message, + }).catch((error: unknown) => { + this.logger.error(error, 'failed to persist chat request state'); + }); + } + + private findSessionByRequestId(requestId: string) { + for (const session of this.sessions.values()) { + if (session.queue.some((item) => item.requestId === requestId)) { + return session; + } + + if ( + session.eventHistory.some((event) => { + if (event.type === 'chat:job') { + return event.payload.requestId === requestId; + } + + if (event.type === 'chat:message' || event.type === 'chat:message:update') { + return event.payload.clientRequestId === requestId; + } + + return false; + }) + ) { + return session; + } + } + + return null; + } + + private async sendOfflineNotificationIfNeeded(session: ChatSessionState, message: ChatMessage) { + if (!session.clientId?.trim()) { + return; + } + + if (message.author !== 'codex' || isPreparingChatReply(message.text)) { + return; + } + + const conversation = await getChatConversation(session.sessionId, session.clientId); + + if (!conversation?.notifyOffline) { + return; + } + + const notificationPayload = await this.buildOfflineChatNotificationPayload( + session, + message, + conversation.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ', + ); + + if (!notificationPayload) { + return; + } + + await this.createOfflineAppNotificationIfNeeded(notificationPayload); + + if (!ensureChatWebPushConfigured()) { + return; + } + + const preferredClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId).catch(() => []); + const notificationTargetClientIds = collectOfflineNotificationClientIds(session.clientId, preferredClientIds); + + if (!notificationTargetClientIds.length) { + return; + } + + const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ + is_enabled: true, + }) + .andWhere((builder) => { + builder.whereIn('device_id', notificationTargetClientIds).orWhereNull('device_id').orWhere('device_id', ''); + }) + .select('endpoint', 'subscription_json'); + + if (!rows.length) { + return; + } + + const uniqueRows = rows.filter((row, index, currentRows) => { + const endpoint = String(row.endpoint ?? '').trim(); + + if (!endpoint) { + return false; + } + + return currentRows.findIndex((candidate) => String(candidate.endpoint ?? '').trim() === endpoint) === index; + }); + + const payloadText = JSON.stringify({ + title: notificationPayload.title, + body: notificationPayload.body, + data: notificationPayload.data, + threadId: notificationPayload.threadId, + }); + await Promise.all( + uniqueRows.map(async (row) => { + try { + await webpush.sendNotification(row.subscription_json as Record, payloadText); + } catch (error: any) { + const statusCode = Number(error?.statusCode ?? 0); + + this.logger.warn( + { + endpoint: String(row.endpoint ?? ''), + statusCode: statusCode || undefined, + detail: normalizeNotificationDetailText(error?.body) ?? normalizeNotificationDetailText(error?.message), + }, + 'chat webpush delivery failed; subscription preserved', + ); + } + }), + ); + } + + private async createOfflineAppNotificationIfNeeded( + payload: { + title: string; + body: string; + previewText: string; + metadata: Record; + linkUrl: string; + }, + ) { + await createNotificationMessage({ + title: payload.title, + body: payload.body, + category: 'chat', + source: 'codex-live', + priority: 'normal', + metadata: { + ...payload.metadata, + linkUrl: payload.linkUrl, + linkLabel: '์ฑ„ํŒ… ๋ฐ”๋กœ ์—ด๊ธฐ', + previewText: payload.previewText, + }, + }); + } + + private async buildOfflineChatNotificationPayload( + session: ChatSessionState, + message: ChatMessage, + conversationTitle: string, + ) { + const isBackgroundedSession = session.context?.pageVisibilityState === 'hidden'; + + if (session.socket && session.socket.readyState === SOCKET_READY_STATE_OPEN && !isBackgroundedSession) { + return null; + } + + const answerText = String(message.text ?? '').trim(); + + if (!answerText || isPreparingChatReply(answerText)) { + return null; + } + + const linkUrl = buildChatNotificationTargetUrl(session.context, session.sessionId); + const requests = message.clientRequestId?.trim() + ? await listChatConversationRequests(session.sessionId, 200).catch(() => []) + : []; + const matchingRequest = + requests.find((request) => request.requestId === message.clientRequestId) ?? + requests.find((request) => request.responseMessageId === message.id) ?? + null; + const questionText = matchingRequest?.userText?.trim() || ''; + const fallback = createChatNotificationPreview(answerText) || `${conversationTitle} ์ฑ„ํŒ…๋ฐฉ์— ์ƒˆ ์‘๋‹ต์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค.`; + const body = createChatQuestionAnswerNotificationBody({ + questionText, + answerText, + fallback, + }); + const previewText = createChatQuestionOnlyNotificationPreview(questionText, fallback); + + if (!body.trim()) { + return null; + } + + const metadata = { + category: 'chat', + sessionId: session.sessionId, + conversationTitle, + messageId: String(message.id), + messageTimestamp: message.timestamp, + questionText: normalizeNotificationDetailText(questionText) ?? '', + answerText: normalizeNotificationDetailText(answerText) ?? '', + targetUrl: linkUrl, + notificationKey: `chat:${session.sessionId}:${message.id}`, + type: 'chat-reply', + }; + + return { + title: 'Codex Live ์ƒˆ ๋ฉ”์‹œ์ง€', + body, + previewText, + linkUrl, + threadId: `chat:${session.sessionId}`, + data: metadata, + metadata, + }; + } + + private replaySessionHistory(session: ChatSessionState, lastEventId: number) { + if (!Number.isFinite(lastEventId) || lastEventId <= 0 || session.eventHistory.length === 0) { + return; + } + + const pendingEnvelopes = session.eventHistory.filter((envelope) => envelope.eventId > lastEventId); + + for (const envelope of pendingEnvelopes) { + sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to replay websocket session history envelope'); + } + } + + private async initializeSession(session: ChatSessionState) { + await session.messagePersistenceTail.catch(() => undefined); + + const messages = await listChatConversationMessages(session.sessionId, { limit: 500 }); + + const initEnvelope = this.createSessionEnvelope(session, { + type: 'chat:init', + payload: { + messages, + }, + }); + + sendSocketEnvelope(this.logger, session.socket, initEnvelope, 'failed to send websocket init envelope'); + + const statusEnvelope = this.createSessionEnvelope(session, { + type: 'chat:status', + payload: { + connectedAt: new Date().toISOString(), + }, + }); + + sendSocketEnvelope(this.logger, session.socket, statusEnvelope, 'failed to send websocket status envelope'); + + this.sendToSession(session, { + type: 'chat:runtime', + payload: chatRuntimeService.getSnapshot(), + }); + } + + private async handleConnection(socket: WebSocket, request: IncomingMessage) { + const origin = request.headers.host ? `http://${request.headers.host}` : 'http://localhost'; + const url = new URL(request.url ?? '/', origin); + const requestedSessionId = url.searchParams.get('sessionId')?.trim() || createRequestId(); + const clientId = url.searchParams.get('clientId')?.trim() || null; + const lastEventIdRaw = Number(url.searchParams.get('lastEventId') ?? 0); + const lastEventId = Number.isFinite(lastEventIdRaw) && lastEventIdRaw > 0 ? lastEventIdRaw : 0; + const session = this.getOrCreateSession(requestedSessionId, clientId); + + if (session.socket && session.socket !== socket) { + closeSocketSafely(this.logger, session.socket, 'failed to close superseded websocket session'); + } + + session.socket = socket; + session.lastSeenAt = Date.now(); + this.clientStates.set(socket, session); + + socket.on('message', (raw: RawData) => { + this.handleMessage(socket, raw); + }); + + socket.on('close', () => { + this.clientStates.delete(socket); + if (session.socket === socket) { + session.socket = null; + } + }); + + socket.on('error', (error: Error) => { + this.logger.error(error, 'chat websocket error'); + this.clientStates.delete(socket); + if (session.socket === socket) { + session.socket = null; + } + }); + + await this.initializeSession(session); + this.replaySessionHistory(session, lastEventId); + } + + private handleMessage(socket: WebSocket, raw: RawData) { + try { + const message = JSON.parse(raw.toString()) as ChatInboundMessage; + const session = this.clientStates.get(socket); + + if (session) { + session.lastSeenAt = Date.now(); + } + + if (message.type === 'context:update') { + this.handleContextUpdate(socket, message.payload); + return; + } + + if (message.type === 'presence:ping') { + return; + } + + if (message.type === 'event:received') { + const receivedEventId = Math.round(Number(message.payload?.eventId ?? 0)); + + if ( + session && + Number.isFinite(receivedEventId) && + receivedEventId > 0 && + session.pendingQueueReleaseEventId !== null && + receivedEventId >= session.pendingQueueReleaseEventId + ) { + session.pendingQueueReleaseEventId = null; + this.tryStartNextQueuedRequest(session); + } + return; + } + + if (message.type === 'message:send') { + void this.handleUserMessage( + socket, + message.payload.text, + message.payload.requestId, + message.payload.mode === 'direct' ? 'direct' : 'queue', + { + chatTypeId: message.payload.chatTypeId ?? null, + chatTypeLabel: message.payload.chatTypeLabel, + chatTypeDescription: message.payload.chatTypeDescription, + chatTypeIsTemplate: message.payload.chatTypeIsTemplate, + }, + ).catch((error: unknown) => { + this.logger.error(error, 'chat reply build failed'); + const session = this.clientStates.get(socket); + + if (!session) { + return; + } + + this.sendToSession(session, { + type: 'chat:error', + payload: { + message: + error instanceof Error && error.message.trim() + ? error.message.trim() + : '์ฑ„ํŒ… ์‘๋‹ต์„ ๋งŒ๋“œ๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + }, + }); + }); + return; + } + + if (message.type === 'runtime:watch') { + if (!session) { + return; + } + + session.watchedRuntimeRequestId = message.payload?.requestId?.trim() || null; + this.pushRuntimeDetail(session); + } + } catch (error) { + this.logger.warn(error, 'invalid chat websocket payload'); + const session = this.clientStates.get(socket); + + if (!session) { + return; + } + + this.sendToSession(session, { + type: 'chat:error', + payload: { + message: '์ฑ„ํŒ… ๋ฉ”์‹œ์ง€๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }, + }); + } + } + + private handleContextUpdate(socket: WebSocket, context: ChatContext) { + const state = this.clientStates.get(socket); + + if (!state) { + return; + } + + const previousFocus = state.context?.focusedComponentId; + const previousContextLabel = state.context?.chatTypeLabel ?? null; + const previousContextDescription = state.context?.chatTypeDescription ?? null; + state.context = context; + const nextContextLabel = context.chatTypeLabel ?? null; + const nextContextDescription = context.chatTypeDescription ?? null; + + if (previousContextLabel !== nextContextLabel || previousContextDescription !== nextContextDescription) { + void updateChatConversationContext(state.sessionId, { + clientId: state.clientId, + contextLabel: nextContextLabel, + contextDescription: nextContextDescription, + }).catch((error: unknown) => { + this.logger.error(error, 'failed to persist chat context'); + }); + } + + if (context.focusedComponentId && context.focusedComponentId !== previousFocus) { + this.sendToSession(state, { + type: 'chat:message', + payload: createMessage('codex', `์„ ํƒ ํฌ์ปค์Šค๊ฐ€ ${context.focusedComponentId}๋กœ ์ด๋™ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ด€๋ จ ๋ฌธ๋งฅ์„ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.`), + }); + } + } + + private async handleUserMessage( + socket: WebSocket, + text: string, + requestId?: string, + mode: 'queue' | 'direct' = 'queue', + contextOverride?: Partial | null, + ) { + const trimmed = text.trim(); + + if (!trimmed) { + return; + } + + const state = this.clientStates.get(socket); + + if (!state) { + return; + } + + if (contextOverride) { + state.context = { + ...(state.context ?? { + pageId: null, + pageTitle: '', + topMenu: '', + focusedComponentId: null, + pageUrl: '', + }), + ...contextOverride, + }; + } + + this.normalizeSessionExecutionState(state); + + const nextRequestId = requestId?.trim() || createRequestId(); + const requestedAtMs = Date.now(); + + const request = { + requestId: nextRequestId, + text: trimmed, + mode, + requestedAtMs, + }; + + if (mode === 'queue' && (state.activeRequestCount > 0 || state.queue.length > 0)) { + const queuedUserMessage = { + ...createMessage('user', trimmed, nextRequestId), + timestamp: formatTime(new Date(requestedAtMs)), + }; + + this.sendToSession(state, { + type: 'chat:message', + payload: queuedUserMessage, + }); + state.queue.push(request); + chatRuntimeService.enqueueJob({ + sessionId: state.sessionId, + requestId: nextRequestId, + mode, + text: trimmed, + }); + chatRuntimeService.registerQueuedControl(nextRequestId, { + remove: () => this.removeQueuedRuntimeJob(nextRequestId), + }); + this.emitJobState(state, { + requestId: nextRequestId, + status: 'queued', + mode, + queueSize: state.queue.length, + message: `๋Œ€๊ธฐ์—ด ${state.queue.length}๊ฑด`, + }); + void upsertChatConversationRequest(state.sessionId, { + requestId: nextRequestId, + status: 'queued', + statusMessage: `๋Œ€๊ธฐ์—ด ${state.queue.length}๊ฑด`, + userMessageId: queuedUserMessage.id, + userText: trimmed, + }).catch((error: unknown) => { + this.logger.error(error, 'failed to persist queued chat request'); + }); + return; + } + + await this.executeRequest(state, request); + } + + private async executeRequest( + session: ChatSessionState, + request: { + requestId: string; + text: string; + mode: 'queue' | 'direct'; + requestedAtMs: number; + }, + ) { + let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; + let hasAnnouncedStreaming = false; + const activityLines: string[] = []; + session.activeRequestCount += 1; + const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId); + const hasStoredUserMessage = existingRequest?.userMessageId != null; + + if (!hasStoredUserMessage) { + this.sendToSession(session, { + type: 'chat:message', + payload: { + ...createMessage('user', request.text, request.requestId), + timestamp: formatTime(new Date(request.requestedAtMs)), + }, + }); + } + + const appendActivityLine = (line: string) => { + const normalized = line.trim(); + + if (!normalized) { + return; + } + + activityLines.push(normalized); + void appendChatConversationActivityLine(session.sessionId, request.requestId, normalized).catch((error: unknown) => { + this.logger.warn(error, 'failed to persist chat activity line'); + }); + this.sendToSession(session, { + type: 'chat:activity', + payload: { + requestId: request.requestId, + line: normalized, + lineCount: activityLines.length, + }, + }); + const activityMessage = createActivityLogMessage(request.requestId, activityLines); + + if (activityMessage) { + this.updateMessageInSession(session, activityMessage); + } + }; + + chatRuntimeService.startJob({ + sessionId: session.sessionId, + requestId: request.requestId, + mode: request.mode, + text: request.text, + }); + chatRuntimeService.registerRunningControl(request.requestId, { + cancel: () => this.cancelRuntimeJob(request.requestId), + }); + chatRuntimeService.appendLog(request.requestId, `์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. mode=${request.mode}`); + appendActivityLine(`# ์ƒํƒœ: ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. mode=${request.mode}`); + this.emitJobState(session, { + requestId: request.requestId, + status: 'started', + mode: request.mode, + queueSize: session.queue.length, + message: + request.mode === 'direct' + ? '์ฆ‰์‹œ ์š”์ฒญ ์‹คํ–‰ ์ค‘' + : `์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘${session.queue.length > 0 ? ` ยท ๋Œ€๊ธฐ์—ด ${session.queue.length}๊ฑด` : ''}`, + }); + + this.sendToSession(session, { + type: 'chat:message', + payload: createMessage( + 'system', + request.mode === 'direct' + ? '์ฆ‰์‹œ ์š”์ฒญ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.' + : `์š”์ฒญ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.${session.queue.length > 0 ? ` ๋‚จ์€ ๋Œ€๊ธฐ์—ด ${session.queue.length}๊ฑด` : ''}`, + request.requestId, + ), + }); + + const progressMessages = buildProgressMessages(request.text); + let progressIndex = 0; + let progressTimer: ReturnType | null = setInterval(() => { + const nextMessage = progressMessages[Math.min(progressIndex, progressMessages.length - 1)]; + chatRuntimeService.appendLog(request.requestId, nextMessage); + appendActivityLine(`# ์ง„ํ–‰: ${nextMessage}`); + + this.sendToSession(session, { + type: 'chat:message', + payload: createMessage('system', nextMessage, request.requestId), + }); + + if (progressIndex < progressMessages.length - 1) { + progressIndex += 1; + } + }, 2200); + + const codexReplyMessage = createMessage('codex', '', request.requestId); + const stopProgressTimer = () => { + if (progressTimer !== null) { + clearInterval(progressTimer); + progressTimer = null; + } + }; + + try { + chatRuntimeService.appendLog(request.requestId, '์š”์ฒญ ๋ถ„์„์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.'); + appendActivityLine('# ์ง„ํ–‰: ์š”์ฒญ ๋ถ„์„์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.'); + this.sendToSession(session, { + type: 'chat:message', + payload: createMessage('system', progressMessages[0] ?? '์š”์ฒญ์„ ๋ถ„์„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.', request.requestId), + }); + this.updateMessageInSession(session, { + ...codexReplyMessage, + text: '์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...', + timestamp: resolveResponseTimestamp(request.requestedAtMs), + }); + + const reply = await buildCodexReply( + session.context ?? null, + request.text, + session.sessionId, + request.requestId, + (partialReply) => { + stopProgressTimer(); + if (!hasAnnouncedStreaming) { + hasAnnouncedStreaming = true; + chatRuntimeService.appendLog(request.requestId, '์‘๋‹ต์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ „์†ก ์ค‘์ž…๋‹ˆ๋‹ค.'); + appendActivityLine('# ์ง„ํ–‰: ์‘๋‹ต์„ ์‹ค์‹œ๊ฐ„์œผ๋กœ ์ „์†ก ์ค‘์ž…๋‹ˆ๋‹ค.'); + } + this.updateMessageInSession(session, { + ...codexReplyMessage, + text: partialReply, + timestamp: resolveResponseTimestamp(request.requestedAtMs), + }); + }, + (activityLine) => { + appendActivityLine(activityLine); + }, + ); + chatRuntimeService.appendLog(request.requestId, '์‘๋‹ต ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + appendActivityLine('# ์ƒํƒœ: ์‘๋‹ต ์ƒ์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + + const finalCodexReplyMessage = { + ...codexReplyMessage, + text: reply, + timestamp: resolveResponseTimestamp(request.requestedAtMs), + }; + + this.sendToSession( + session, + { + type: 'chat:message', + payload: finalCodexReplyMessage, + }, + { + skipOfflineNotification: true, + }, + ); + + // Final replies must be durably stored before the request is marked complete, + // otherwise replay/reload can be stuck on the placeholder text. + await this.persistConversationMessage(session, finalCodexReplyMessage); + await upsertChatConversationRequest(session.sessionId, { + requestId: request.requestId, + status: 'completed', + statusMessage: '์š”์ฒญ ์ฒ˜๋ฆฌ ์™„๋ฃŒ', + responseMessageId: finalCodexReplyMessage.id, + responseText: finalCodexReplyMessage.text, + }); + + const terminalMessageEnvelope = this.sendToSession(session, { + type: 'chat:message', + payload: createMessage( + 'system', + request.mode === 'direct' + ? '์ฆ‰์‹œ ์š”์ฒญ ์ฒ˜๋ฆฌ๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค.' + : `์š”์ฒญ ์ฒ˜๋ฆฌ๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค.${session.queue.length > 0 ? ` ๋Œ€๊ธฐ์—ด ${session.queue.length}๊ฑด์ด ์ด์–ด์ง‘๋‹ˆ๋‹ค.` : ''}`, + request.requestId, + ), + }); + session.pendingQueueReleaseEventId = terminalMessageEnvelope.eventId; + this.emitJobState(session, { + requestId: request.requestId, + status: 'completed', + mode: request.mode, + queueSize: session.queue.length, + message: '์š”์ฒญ ์ฒ˜๋ฆฌ ์™„๋ฃŒ', + }); + chatRuntimeService.finishJob(request.requestId, 'completed'); + await this.sendOfflineNotificationIfNeeded(session, finalCodexReplyMessage); + } catch (error) { + const wasCancelled = this.cancelledRequestIds.has(request.requestId); + terminalStatus = wasCancelled ? 'cancelled' : 'failed'; + chatRuntimeService.appendLog( + request.requestId, + wasCancelled + ? '์‚ฌ์šฉ์ž ์š”์ฒญ์œผ๋กœ ์‹คํ–‰์ด ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' + : error instanceof Error + ? `์‹คํ–‰ ์˜ค๋ฅ˜: ${error.message}` + : '์‹คํ–‰ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + appendActivityLine( + wasCancelled + ? '# ์ƒํƒœ: ์‚ฌ์šฉ์ž ์š”์ฒญ์œผ๋กœ ์‹คํ–‰์ด ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' + : error instanceof Error + ? `# ์˜ค๋ฅ˜: ${error.message}` + : '# ์˜ค๋ฅ˜: ์‹คํ–‰ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + const terminalErrorEnvelope = this.sendToSession(session, { + type: 'chat:message', + payload: createMessage( + 'system', + wasCancelled ? '์š”์ฒญ ์‹คํ–‰์ด ์ค‘๋‹จ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' : '์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + request.requestId, + ), + }); + session.pendingQueueReleaseEventId = terminalErrorEnvelope.eventId; + this.emitJobState(session, { + requestId: request.requestId, + status: 'failed', + mode: request.mode, + queueSize: session.queue.length, + message: wasCancelled ? '์š”์ฒญ ์‹คํ–‰ ์ค‘๋‹จ' : '์š”์ฒญ ์ฒ˜๋ฆฌ ์‹คํŒจ', + }); + throw error; + } finally { + stopProgressTimer(); + activeChatProcessRegistry.delete(request.requestId); + if (chatRuntimeService.getJobDetail(request.requestId).terminalStatus == null) { + chatRuntimeService.finishJob(request.requestId, terminalStatus); + } + this.cancelledRequestIds.delete(request.requestId); + session.activeRequestCount = Math.max(0, session.activeRequestCount - 1); + this.tryStartNextQueuedRequest(session); + } + } +} diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts new file mode 100755 index 0000000..4d2ca4c --- /dev/null +++ b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts @@ -0,0 +1,460 @@ +import { createHash } from 'node:crypto'; +import { db } from '../db/client.js'; +import { listBoardPosts, createBoardPost } from './board-service.js'; +import { listErrorLogs } from './error-log-service.js'; +import { ensurePlanTable, PLAN_TABLE } from './plan-service.js'; + +const DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT = 6; +const ERROR_LOG_BOARD_POST_MARKER_PREFIX = '`; +} + +function buildErrorLogBoardPostTitle(candidate: ErrorLogCandidate) { + const scope = candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown' + ? ` ${candidate.requestPathGroup}` + : ''; + const title = `์—๋Ÿฌ๋กœ๊ทธ ์กฐ์น˜ ๊ณ„ํš: ${candidate.errorType}${scope}`.replace(/\s+/g, ' ').trim(); + return title.length > 200 ? `${title.slice(0, 197).trimEnd()}...` : title; +} + +function buildErrorLogBoardPostContent(candidate: ErrorLogCandidate, rangeStart: Date, rangeEnd: Date) { + const detailNote = formatErrorLogPlanNote(candidate, rangeStart, rangeEnd); + return [ + buildBoardPostMarker(candidate.workId), + `# ${buildErrorLogBoardPostTitle(candidate)}`, + '', + detailNote, + ].join('\n'); +} + +export async function registerErrorLogBoardPosts(args?: { + rangeStart?: Date; + rangeEnd?: Date; + maxGroups?: number; +}) { + await ensurePlanTable(); + + const rangeEnd = args?.rangeEnd ?? new Date(); + const rangeStart = args?.rangeStart ?? new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000); + const maxGroups = args?.maxGroups ?? DEFAULT_ERROR_LOG_PLAN_GROUP_LIMIT; + const [errorLogs, existingBoardPosts] = await Promise.all([ + listErrorLogs(200), + listBoardPosts(), + ]); + + const recentLogs = filterLogsWithinRange(errorLogs as Array>, rangeStart, rangeEnd); + const rawCandidates = buildErrorLogPlanCandidates(recentLogs as Array>); + const candidates = coalesceErrorLogPlanCandidates(rawCandidates, maxGroups); + const createdPosts: ErrorLogBoardPostRegistration[] = []; + const skippedPosts: ErrorLogRegistrationSkip[] = []; + + for (const candidate of candidates) { + const marker = buildBoardPostMarker(candidate.workId); + const existingBoardPost = existingBoardPosts.find((post) => String(post.content ?? '').includes(marker)); + + if (existingBoardPost) { + skippedPosts.push({ + workId: candidate.workId, + boardPostId: existingBoardPost.id, + reason: `๊ธฐ์กด ๊ฒŒ์‹œ๊ธ€ #${existingBoardPost.id}๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.`, + }); + continue; + } + + const latestOpenPlan = await db(PLAN_TABLE) + .select(['id', 'status']) + .where({ work_id: candidate.workId }) + .whereNot({ status: '์™„๋ฃŒ' as never }) + .orderBy('id', 'desc') + .first(); + + if (latestOpenPlan) { + skippedPosts.push({ + workId: candidate.workId, + planId: Number(latestOpenPlan.id), + reason: `๊ธฐ์กด ๋ฏธ์™„๋ฃŒ Plan #${latestOpenPlan.id}๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค.`, + }); + continue; + } + + const createdPost = await createBoardPost({ + title: buildErrorLogBoardPostTitle(candidate), + content: buildErrorLogBoardPostContent(candidate, rangeStart, rangeEnd), + automationType: 'none', + }); + + createdPosts.push({ + postId: createdPost.id, + title: createdPost.title, + workId: candidate.workId, + count: candidate.count, + }); + } + + return { + rangeStart, + rangeEnd, + recentLogs, + rawCandidates, + candidates, + createdPosts, + skippedPosts, + }; +} diff --git a/etc/servers/work-server/src/services/error-log-service.ts b/etc/servers/work-server/src/services/error-log-service.ts new file mode 100755 index 0000000..703cf1f --- /dev/null +++ b/etc/servers/work-server/src/services/error-log-service.ts @@ -0,0 +1,164 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; + +export const ERROR_LOG_TABLE = 'error_logs'; +export const ERROR_LOG_VIEW_TOKEN = 'usr_7f3a9c2d8e1b4a6f'; + +export const createErrorLogSchema = z.object({ + source: z.enum(['server', 'client', 'automation']).default('server'), + sourceLabel: z.string().trim().max(80).optional().nullable(), + errorType: z.string().trim().min(1).max(120), + errorName: z.string().trim().max(255).optional().nullable(), + errorMessage: z.string().trim().min(1).max(10000), + detail: z.string().trim().max(50000).optional().nullable(), + stackTrace: z.string().trim().max(50000).optional().nullable(), + statusCode: z.number().int().min(100).max(599).optional().nullable(), + requestMethod: z.string().trim().max(10).optional().nullable(), + requestPath: z.string().trim().max(1000).optional().nullable(), + relatedPlanId: z.number().int().positive().optional().nullable(), + relatedWorkId: z.string().trim().max(120).optional().nullable(), + context: z.record(z.string(), z.unknown()).optional().nullable(), +}); + +export type ErrorLogPayload = z.infer; + +async function ensureErrorLogTable() { + const hasTable = await db.schema.hasTable(ERROR_LOG_TABLE); + + if (!hasTable) { + await db.schema.createTable(ERROR_LOG_TABLE, (table) => { + table.increments('id').primary(); + table.string('source', 20).notNullable().defaultTo('server'); + table.string('source_label', 80).nullable(); + table.string('error_type', 120).notNullable(); + table.string('error_name', 255).nullable(); + table.text('error_message').notNullable(); + table.text('detail').nullable(); + table.text('stack_trace').nullable(); + table.integer('status_code').nullable(); + table.string('request_method', 10).nullable(); + table.string('request_path', 1000).nullable(); + table.integer('related_plan_id').nullable(); + table.string('related_work_id', 120).nullable(); + table.jsonb('context_json').nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['source', (table) => table.string('source', 20).notNullable().defaultTo('server')], + ['source_label', (table) => table.string('source_label', 80).nullable()], + ['error_type', (table) => table.string('error_type', 120).notNullable().defaultTo('unknown')], + ['error_name', (table) => table.string('error_name', 255).nullable()], + ['error_message', (table) => table.text('error_message').notNullable().defaultTo('')], + ['detail', (table) => table.text('detail').nullable()], + ['stack_trace', (table) => table.text('stack_trace').nullable()], + ['status_code', (table) => table.integer('status_code').nullable()], + ['request_method', (table) => table.string('request_method', 10).nullable()], + ['request_path', (table) => table.string('request_path', 1000).nullable()], + ['related_plan_id', (table) => table.integer('related_plan_id').nullable()], + ['related_work_id', (table) => table.string('related_work_id', 120).nullable()], + ['context_json', (table) => table.jsonb('context_json').nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(ERROR_LOG_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(ERROR_LOG_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +function normalizePayload(payload: ErrorLogPayload) { + const parsedPayload = createErrorLogSchema.parse(payload); + const sourceLabel = + parsedPayload.sourceLabel ?? + (parsedPayload.source === 'client' + ? 'ํ”„๋ก ํŠธ์—”๋“œ' + : parsedPayload.source === 'automation' + ? 'Plan ์ž๋™ํ™”' + : '์›Œํฌ์„œ๋ฒ„ API'); + + return { + source: parsedPayload.source, + source_label: sourceLabel, + error_type: parsedPayload.errorType, + error_name: parsedPayload.errorName ?? null, + error_message: parsedPayload.errorMessage, + detail: parsedPayload.detail ?? null, + stack_trace: parsedPayload.stackTrace ?? null, + status_code: parsedPayload.statusCode ?? null, + request_method: parsedPayload.requestMethod ?? null, + request_path: parsedPayload.requestPath ?? null, + related_plan_id: parsedPayload.relatedPlanId ?? null, + related_work_id: parsedPayload.relatedWorkId ?? null, + context_json: parsedPayload.context ?? null, + }; +} + +function mapErrorLogRow(row: Record) { + return { + id: Number(row.id), + source: String(row.source ?? 'server'), + sourceLabel: row.source_label ? String(row.source_label) : null, + errorType: String(row.error_type ?? ''), + errorName: row.error_name ? String(row.error_name) : null, + errorMessage: String(row.error_message ?? ''), + detail: row.detail ? String(row.detail) : null, + stackTrace: row.stack_trace ? String(row.stack_trace) : null, + statusCode: typeof row.status_code === 'number' ? row.status_code : row.status_code ? Number(row.status_code) : null, + requestMethod: row.request_method ? String(row.request_method) : null, + requestPath: row.request_path ? String(row.request_path) : null, + relatedPlanId: + typeof row.related_plan_id === 'number' + ? row.related_plan_id + : row.related_plan_id + ? Number(row.related_plan_id) + : null, + relatedWorkId: row.related_work_id ? String(row.related_work_id) : null, + context: row.context_json && typeof row.context_json === 'object' ? (row.context_json as Record) : null, + createdAt: row.created_at, + }; +} + +export async function setupErrorLogTable() { + await ensureErrorLogTable(); + + return { + ok: true, + table: ERROR_LOG_TABLE, + }; +} + +export async function createErrorLog(payload: ErrorLogPayload) { + await ensureErrorLogTable(); + + const [row] = await db(ERROR_LOG_TABLE) + .insert({ + ...normalizePayload(payload), + created_at: db.fn.now(), + }) + .returning('*'); + + return mapErrorLogRow(row); +} + +export async function listErrorLogs(limit = 50) { + await ensureErrorLogTable(); + + const safeLimit = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), 200) : 50; + const rows = await db(ERROR_LOG_TABLE).select('*').orderBy('created_at', 'desc').limit(safeLimit); + + return rows.map((row) => mapErrorLogRow(row)); +} + +export function hasErrorLogViewAccessToken(token: string | string[] | undefined) { + const normalizedToken = Array.isArray(token) ? token[0] : token; + return String(normalizedToken ?? '').trim() === ERROR_LOG_VIEW_TOKEN; +} diff --git a/etc/servers/work-server/src/services/git-service.test.ts b/etc/servers/work-server/src/services/git-service.test.ts new file mode 100755 index 0000000..66d40f1 --- /dev/null +++ b/etc/servers/work-server/src/services/git-service.test.ts @@ -0,0 +1,150 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + ensureBranchExists, + mergeBranchToRelease, + mergeReleaseToMain, + type GitAutomationConfig, +} from './git-service.js'; + +const execFileAsync = promisify(execFile); + +async function runGit(repoPath: string, args: string[]) { + const { stdout } = await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, ...args], { + encoding: 'utf8', + env: { + ...process.env, + GIT_AUTHOR_NAME: 'test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }, + }); + + return stdout.trim(); +} + +async function createRepo() { + const rootPath = await mkdtemp(path.join(tmpdir(), 'git-service-test-')); + const remotePath = path.join(rootPath, 'remote.git'); + const repoPath = path.join(rootPath, 'work'); + const config: GitAutomationConfig = { + repoPath, + mainBranch: 'main', + releaseBranch: 'release', + }; + + await execFileAsync('git', ['init', '--bare', '--initial-branch=main', remotePath], { + encoding: 'utf8', + }); + await execFileAsync('git', ['clone', remotePath, repoPath], { + encoding: 'utf8', + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0', + }, + }); + + await runGit(repoPath, ['config', 'user.name', 'test']); + await runGit(repoPath, ['config', 'user.email', 'test@example.com']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main root']); + await runGit(repoPath, ['push', '-u', 'origin', 'main']); + + await runGit(repoPath, ['switch', '-c', 'release']); + await runGit(repoPath, ['push', '-u', 'origin', 'release']); + await runGit(repoPath, ['switch', 'main']); + + return { repoPath, config }; +} + +test('ensureBranchExists creates feature branch from main even when release diverged', async () => { + const { repoPath, config } = await createRepo(); + + await runGit(repoPath, ['switch', 'main']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']); + await runGit(repoPath, ['push', 'origin', 'main']); + const mainHead = await runGit(repoPath, ['rev-parse', 'HEAD']); + + await runGit(repoPath, ['switch', 'release']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release only']); + await runGit(repoPath, ['push', 'origin', 'release']); + const releaseHead = await runGit(repoPath, ['rev-parse', 'HEAD']); + + await ensureBranchExists(config, 'feature/test-branch', 'release'); + + const featureHead = await runGit(repoPath, ['rev-parse', 'feature/test-branch']); + + assert.equal(featureHead, mainHead); + assert.notEqual(featureHead, releaseHead, 'feature branch should point to main head, not release head'); +}); + +test('ensureBranchExists recreates missing local main from origin/main', async () => { + const { repoPath, config } = await createRepo(); + + await runGit(repoPath, ['switch', 'main']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']); + await runGit(repoPath, ['push', 'origin', 'main']); + const mainHead = await runGit(repoPath, ['rev-parse', 'main']); + + await runGit(repoPath, ['branch', '-D', 'main']); + + await ensureBranchExists(config, 'feature/recreated-from-origin-main'); + + const recreatedMainHead = await runGit(repoPath, ['rev-parse', 'main']); + const featureHead = await runGit(repoPath, ['rev-parse', 'feature/recreated-from-origin-main']); + + assert.equal(recreatedMainHead, mainHead); + assert.equal(featureHead, mainHead); +}); + +test('mergeBranchToRelease squashes hotfix changes into release', async () => { + const { repoPath, config } = await createRepo(); + + await runGit(repoPath, ['switch', 'main']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']); + await runGit(repoPath, ['push', 'origin', 'main']); + + await runGit(repoPath, ['switch', 'release']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release base']); + await runGit(repoPath, ['push', 'origin', 'release']); + + await runGit(repoPath, ['switch', '-c', 'hotfix/test']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'hotfix change']); + await runGit(repoPath, ['push', '-u', 'origin', 'hotfix/test']); + const hotfixHead = await runGit(repoPath, ['rev-parse', 'HEAD']); + + await mergeBranchToRelease(config, 'hotfix/test', 'release'); + + const releaseMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'release']); + const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'release']); + const releaseHistory = await runGit(repoPath, ['rev-list', 'release']); + + assert.equal(releaseMessage, 'merge: hotfix/test -> release (squash)'); + assert.equal(parentCount.split(' ').length, 2); + assert.ok(!releaseHistory.split('\n').includes(hotfixHead)); +}); + +test('mergeReleaseToMain keeps release to main as a normal merge commit', async () => { + const { repoPath, config } = await createRepo(); + + await runGit(repoPath, ['switch', 'main']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'main base']); + await runGit(repoPath, ['push', 'origin', 'main']); + + await runGit(repoPath, ['switch', 'release']); + await runGit(repoPath, ['commit', '--allow-empty', '-m', 'release change']); + await runGit(repoPath, ['push', 'origin', 'release']); + + await mergeReleaseToMain(config, 'release'); + + const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']); + const parentCount = await runGit(repoPath, ['rev-list', '--parents', '-n', '1', 'main']); + + assert.equal(mainMessage, 'merge: release -> main'); + assert.equal(parentCount.split(' ').length, 3); +}); diff --git a/etc/servers/work-server/src/services/git-service.ts b/etc/servers/work-server/src/services/git-service.ts new file mode 100755 index 0000000..ac5b097 --- /dev/null +++ b/etc/servers/work-server/src/services/git-service.ts @@ -0,0 +1,267 @@ +import { execFile } from 'node:child_process'; +import { access, copyFile } from 'node:fs/promises'; +import { constants as fsConstants } from 'node:fs'; +import { promisify } from 'node:util'; +import { getEnv } from '../config/env.js'; + +const execFileAsync = promisify(execFile); +const gitCredentialSourcePath = '/root/.git-credentials'; +const gitCredentialCachePath = '/tmp/work-server-git-credentials'; + +async function prepareWritableCredentialStore() { + try { + await access(gitCredentialSourcePath, fsConstants.R_OK); + await copyFile(gitCredentialSourcePath, gitCredentialCachePath); + return gitCredentialCachePath; + } catch { + return null; + } +} + +export type GitAutomationConfig = { + repoPath: string; + releaseBranch: string; + mainBranch: string; +}; + +async function runGit(repoPath: string, args: string[]) { + const credentialStorePath = await prepareWritableCredentialStore(); + const gitArgs = ['-c', `safe.directory=${repoPath}`]; + const env = getEnv(); + + if (credentialStorePath) { + gitArgs.push('-c', `credential.helper=store --file=${credentialStorePath}`); + } + + await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', env.PLAN_GIT_USER_NAME], { + encoding: 'utf8', + }); + await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', env.PLAN_GIT_USER_EMAIL], { + encoding: 'utf8', + }); + + const { stdout, stderr } = await execFileAsync('git', [...gitArgs, '-C', repoPath, ...args], { + encoding: 'utf8', + }); + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + }; +} + +async function assertBranchExists(repoPath: string, branchName: string) { + try { + await runGit(repoPath, ['rev-parse', '--verify', branchName]); + } catch { + throw new Error( + `${branchName} ๋ธŒ๋žœ์น˜๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋จผ์ € ${branchName} ๋ธŒ๋žœ์น˜๋ฅผ ์ƒ์„ฑํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.`, + ); + } +} + +async function hasLocalBranch(repoPath: string, branchName: string) { + try { + await runGit(repoPath, ['rev-parse', '--verify', '--quiet', branchName]); + return true; + } catch { + return false; + } +} + +async function hasRemoteBranch(repoPath: string, branchName: string) { + try { + await runGit(repoPath, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branchName}`]); + return true; + } catch { + return false; + } +} + +async function syncBranchWithRemote(repoPath: string, branchName: string) { + await runGit(repoPath, ['fetch', 'origin', branchName]); + + if (!(await hasRemoteBranch(repoPath, branchName))) { + return; + } + + if (!(await hasLocalBranch(repoPath, branchName))) { + await runGit(repoPath, ['branch', branchName, `origin/${branchName}`]); + } + + try { + await runGit(repoPath, ['switch', branchName]); + } catch { + await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]); + } + + await runGit(repoPath, ['reset', '--hard', `origin/${branchName}`]); +} + +export async function assertCleanWorktree(repoPath: string) { + const { stdout } = await runGit(repoPath, ['status', '--porcelain']); + + if (stdout) { + throw new Error('Git ์ž‘์—… ๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ๊นจ๋—ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ ๋’ค ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } +} + +export async function cleanAutomationWorktree(repoPath: string) { + await runGit(repoPath, ['reset', '--hard']); + await runGit(repoPath, ['clean', '-fd']); +} + +export async function ensureBranchExists(config: GitAutomationConfig, branchName: string, releaseTarget?: string) { + const baseBranch = config.mainBranch; + + await assertCleanWorktree(config.repoPath); + await syncBranchWithRemote(config.repoPath, baseBranch); + await assertBranchExists(config.repoPath, baseBranch); + await runGit(config.repoPath, ['switch', baseBranch]); + await runGit(config.repoPath, ['switch', '-C', branchName]); +} + +export async function pushBranch(repoPath: string, branchName: string, setUpstream = false) { + await runGit(repoPath, setUpstream ? ['push', '-u', 'origin', branchName] : ['push', 'origin', branchName]); +} + +async function deleteLocalBranch(repoPath: string, branchName: string) { + try { + await runGit(repoPath, ['branch', '-D', branchName]); + } catch { + return; + } +} + +async function deleteRemoteBranch(repoPath: string, branchName: string) { + try { + await runGit(repoPath, ['push', 'origin', '--delete', branchName]); + } catch { + return; + } +} + +export async function commitAllChanges(repoPath: string, message: string) { + await runGit(repoPath, ['add', '-A']); + await runGit(repoPath, ['commit', '-m', message]); +} + +export async function hasWorkingTreeChanges(repoPath: string) { + const { stdout } = await runGit(repoPath, ['status', '--porcelain']); + return Boolean(stdout); +} + +function isHotfixBranch(branchName: string) { + return /^hotfix\//.test(branchName); +} + +function isReleaseBranch(branchName: string, config: GitAutomationConfig) { + return branchName === config.releaseBranch || /^release([/-]|$)/.test(branchName); +} + +function shouldSquashMerge(sourceBranch: string, targetBranch: string, config: GitAutomationConfig) { + return isHotfixBranch(sourceBranch) && ( + targetBranch === config.mainBranch || isReleaseBranch(targetBranch, config) + ); +} + +function assertAllowedMergeDirection( + config: GitAutomationConfig, + sourceBranch: string, + targetBranch: string, +) { + if ( + targetBranch === config.mainBranch && + sourceBranch !== config.releaseBranch && + sourceBranch !== config.mainBranch && + !isHotfixBranch(sourceBranch) + ) { + throw new Error( + `๋ธŒ๋žœ์น˜ ์ „๋žต ์œ„๋ฐ˜: ${sourceBranch} -> ${targetBranch} ์ง์ ‘ ๋จธ์ง€๋Š” ํ—ˆ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. release ๋ฐ˜์˜ ํ›„ main์— ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”.`, + ); + } +} + +export async function mergeBranch( + config: GitAutomationConfig, + sourceBranch: string, + targetBranch: string, +) { + assertAllowedMergeDirection(config, sourceBranch, targetBranch); + await assertCleanWorktree(config.repoPath); + await syncBranchWithRemote(config.repoPath, targetBranch); + await assertBranchExists(config.repoPath, targetBranch); + await assertBranchExists(config.repoPath, sourceBranch); + + if (sourceBranch === config.releaseBranch || sourceBranch === config.mainBranch) { + await syncBranchWithRemote(config.repoPath, sourceBranch); + } + + await runGit(config.repoPath, ['switch', targetBranch]); + if (shouldSquashMerge(sourceBranch, targetBranch, config)) { + await runGit(config.repoPath, ['merge', '--squash', sourceBranch]); + await runGit(config.repoPath, ['commit', '-m', `merge: ${sourceBranch} -> ${targetBranch} (squash)`]); + return; + } + + await runGit(config.repoPath, ['merge', '--no-ff', sourceBranch, '-m', `merge: ${sourceBranch} -> ${targetBranch}`]); +} + +export async function mergeBranchToRelease( + config: GitAutomationConfig, + branchName: string, + releaseTarget?: string, +) { + const baseBranch = releaseTarget || config.releaseBranch; + await mergeBranch(config, branchName, baseBranch); +} + +export async function mergeReleaseToMain( + config: GitAutomationConfig, + releaseTarget?: string, +) { + const baseBranch = releaseTarget || config.releaseBranch; + await mergeBranch(config, baseBranch, config.mainBranch); +} + +export async function mergeIssueBranchToMain( + config: GitAutomationConfig, + branchName: string, +) { + throw new Error( + `๋ธŒ๋žœ์น˜ ์ „๋žต ์œ„๋ฐ˜: ${branchName} -> ${config.mainBranch} ์ง์ ‘ ๋จธ์ง€๋Š” ๋น„ํ™œ์„ฑํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค. release ๋ธŒ๋žœ์น˜๋ฅผ ํ†ตํ•ด ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”.`, + ); +} + +export async function pullMainProjectBranch(repoPath: string, branchName: string) { + await assertCleanWorktree(repoPath); + await runGit(repoPath, ['fetch', 'origin', branchName]); + + try { + await runGit(repoPath, ['switch', branchName]); + } catch { + await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]); + } + + await runGit(repoPath, ['pull', '--ff-only', 'origin', branchName]); +} + +export async function recreateReleaseBranchFromMain( + config: GitAutomationConfig, + releaseTarget?: string, +) { + const targetBranch = releaseTarget || config.releaseBranch; + + if (targetBranch === config.mainBranch) { + throw new Error('release ๋ธŒ๋žœ์น˜๋ฅผ main ๋ธŒ๋žœ์น˜์™€ ๋™์ผํ•˜๊ฒŒ ์žฌ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + await assertCleanWorktree(config.repoPath); + await syncBranchWithRemote(config.repoPath, config.mainBranch); + await assertBranchExists(config.repoPath, config.mainBranch); + await runGit(config.repoPath, ['switch', config.mainBranch]); + await deleteLocalBranch(config.repoPath, targetBranch); + await deleteRemoteBranch(config.repoPath, targetBranch); + await runGit(config.repoPath, ['switch', '-C', targetBranch, config.mainBranch]); + await pushBranch(config.repoPath, targetBranch, true); +} diff --git a/etc/servers/work-server/src/services/notification-message-service.ts b/etc/servers/work-server/src/services/notification-message-service.ts new file mode 100755 index 0000000..17ad497 --- /dev/null +++ b/etc/servers/work-server/src/services/notification-message-service.ts @@ -0,0 +1,244 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; + +export const NOTIFICATION_MESSAGE_TABLE = 'notification_messages'; + +const notificationMessagePrioritySchema = z.enum(['low', 'normal', 'high', 'urgent']); +const notificationMessageListStatusSchema = z.enum(['all', 'unread']); + +export const notificationMessageListQuerySchema = z.object({ + status: notificationMessageListStatusSchema.default('all'), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export const notificationMessagePayloadSchema = z.object({ + title: z.string().trim().min(1).max(200), + body: z.string().trim().min(1).max(20000), + category: z.string().trim().min(1).max(60).default('general'), + source: z.string().trim().min(1).max(80).default('system'), + priority: notificationMessagePrioritySchema.default('normal'), + metadata: z.record(z.string(), z.unknown()).default({}), +}); + +export const notificationMessageReadPayloadSchema = z.object({ + read: z.boolean().default(true), +}); + +export type NotificationMessageItem = { + id: number; + title: string; + body: string; + preview: string; + category: string; + source: string; + priority: z.infer; + read: boolean; + readAt: string | null; + metadata: Record; + createdAt: string; + updatedAt: string; +}; + +function normalizePreviewText(value: string) { + const normalized = value + .replace(/```[\s\S]*?```/g, ' ') + .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/[#>*_`~-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function mapNotificationMessageRow(row: Record): NotificationMessageItem { + const body = String(row.body ?? ''); + const metadata = + typeof row.metadata_json === 'object' && row.metadata_json ? (row.metadata_json as Record) : {}; + const metadataPreview = + typeof metadata.previewText === 'string' + ? metadata.previewText + : typeof metadata.listPreviewText === 'string' + ? metadata.listPreviewText + : ''; + + return { + id: Number(row.id ?? 0), + title: String(row.title ?? ''), + body, + preview: normalizePreviewText(metadataPreview || body), + category: String(row.category ?? 'general'), + source: String(row.source ?? 'system'), + priority: notificationMessagePrioritySchema.catch('normal').parse(row.priority), + read: Boolean(row.is_read), + readAt: row.read_at === null || row.read_at === undefined ? null : String(row.read_at), + metadata, + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} + +function resolveInsertedId(result: unknown): number | null { + if (typeof result === 'number' && Number.isInteger(result) && result > 0) { + return result; + } + + if (Array.isArray(result)) { + const first = result[0]; + + if (typeof first === 'number' && Number.isInteger(first) && first > 0) { + return first; + } + + if (first && typeof first === 'object' && 'id' in first) { + const id = Number((first as { id?: unknown }).id); + return Number.isInteger(id) && id > 0 ? id : null; + } + } + + if (result && typeof result === 'object' && 'id' in result) { + const id = Number((result as { id?: unknown }).id); + return Number.isInteger(id) && id > 0 ? id : null; + } + + return null; +} + +function supportsReturning() { + const clientName = String(db.client.config.client ?? '').toLowerCase(); + return ['pg', 'postgres', 'postgresql', 'sqlite3', 'better-sqlite3', 'oracledb', 'mssql'].includes(clientName); +} + +export async function ensureNotificationMessagesTable() { + const hasTable = await db.schema.hasTable(NOTIFICATION_MESSAGE_TABLE); + + if (!hasTable) { + await db.schema.createTable(NOTIFICATION_MESSAGE_TABLE, (table) => { + table.increments('id').primary(); + table.string('title', 200).notNullable(); + table.text('body').notNullable(); + table.string('category', 60).notNullable().defaultTo('general'); + table.string('source', 80).notNullable().defaultTo('system'); + table.string('priority', 20).notNullable().defaultTo('normal'); + table.boolean('is_read').notNullable().defaultTo(false); + table.timestamp('read_at', { useTz: true }).nullable(); + table.jsonb('metadata_json').notNullable().defaultTo('{}'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['title', (table) => table.string('title', 200).notNullable().defaultTo('์•Œ๋ฆผ')], + ['body', (table) => table.text('body').notNullable().defaultTo('')], + ['category', (table) => table.string('category', 60).notNullable().defaultTo('general')], + ['source', (table) => table.string('source', 80).notNullable().defaultTo('system')], + ['priority', (table) => table.string('priority', 20).notNullable().defaultTo('normal')], + ['is_read', (table) => table.boolean('is_read').notNullable().defaultTo(false)], + ['read_at', (table) => table.timestamp('read_at', { useTz: true }).nullable()], + ['metadata_json', (table) => table.jsonb('metadata_json').notNullable().defaultTo('{}')], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(NOTIFICATION_MESSAGE_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(NOTIFICATION_MESSAGE_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function listNotificationMessages(query: z.infer) { + await ensureNotificationMessagesTable(); + const parsedQuery = notificationMessageListQuerySchema.parse(query); + const builder = db(NOTIFICATION_MESSAGE_TABLE) + .select('*') + .orderBy('is_read', 'asc') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .limit(parsedQuery.limit); + + if (parsedQuery.status === 'unread') { + builder.where({ is_read: false }); + } + + const rows = await builder; + const unreadCountResult = await db(NOTIFICATION_MESSAGE_TABLE) + .where({ is_read: false }) + .count<{ count: string | number }>({ count: '*' }) + .first(); + + return { + items: rows.map((row) => mapNotificationMessageRow(row)), + unreadCount: Number(unreadCountResult?.count ?? 0), + }; +} + +export async function getNotificationMessage(id: number) { + await ensureNotificationMessagesTable(); + const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first(); + return row ? mapNotificationMessageRow(row) : null; +} + +export async function createNotificationMessage(payload: z.infer) { + await ensureNotificationMessagesTable(); + const parsedPayload = notificationMessagePayloadSchema.parse(payload); + const insertQuery = db(NOTIFICATION_MESSAGE_TABLE).insert({ + title: parsedPayload.title, + body: parsedPayload.body, + category: parsedPayload.category, + source: parsedPayload.source, + priority: parsedPayload.priority, + metadata_json: parsedPayload.metadata, + is_read: false, + read_at: null, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }); + const insertResult = supportsReturning() ? await insertQuery.returning('id') : await insertQuery; + const insertedId = resolveInsertedId(insertResult); + + if (!insertedId) { + throw new Error('์•Œ๋ฆผ ๋ฉ”์‹œ์ง€ ์ €์žฅ ํ›„ ID๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id: insertedId }).first(); + + if (!row) { + throw new Error('์ €์žฅ๋œ ์•Œ๋ฆผ ๋ฉ”์‹œ์ง€๋ฅผ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + + return mapNotificationMessageRow(row); +} + +export async function updateNotificationMessageReadState( + id: number, + payload: z.infer, +) { + await ensureNotificationMessagesTable(); + const parsedPayload = notificationMessageReadPayloadSchema.parse(payload); + const updatedCount = await db(NOTIFICATION_MESSAGE_TABLE) + .where({ id }) + .update({ + is_read: parsedPayload.read, + read_at: parsedPayload.read ? db.fn.now() : null, + updated_at: db.fn.now(), + }); + + if (!updatedCount) { + return null; + } + + const row = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).first(); + return row ? mapNotificationMessageRow(row) : null; +} + +export async function deleteNotificationMessage(id: number) { + await ensureNotificationMessagesTable(); + const deletedCount = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).del(); + return deletedCount > 0; +} diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts new file mode 100755 index 0000000..6d074a0 --- /dev/null +++ b/etc/servers/work-server/src/services/notification-service.ts @@ -0,0 +1,900 @@ +import { readFile } from 'node:fs/promises'; +import { Notification, Provider } from '@parse/node-apn'; +import webpush from 'web-push'; +import { z } from 'zod'; +import { getEnv } from '../config/env.js'; +import { db } from '../db/client.js'; +import { ensureNotificationMessagesTable } from './notification-message-service.js'; + +export const NOTIFICATION_TOKEN_TABLE = 'notification_tokens'; +export const WEB_PUSH_SUBSCRIPTION_TABLE = 'web_push_subscriptions'; +export const NOTIFICATION_PREFERENCE_TABLE = 'notification_preferences'; + +const automationNotificationPreferenceSchema = z.object({ + notifyOnAutomationStart: z.boolean().optional(), + notifyOnAutomationProgress: z.boolean().optional(), + notifyOnAutomationCompletion: z.boolean().optional(), + notifyOnAutomationRelease: z.boolean().optional(), + notifyOnAutomationMain: z.boolean().optional(), + notifyOnAutomationFailure: z.boolean().optional(), + notifyOnAutomationRestart: z.boolean().optional(), + notifyOnAutomationIssueResolved: z.boolean().optional(), +}); + +const notificationTargetKindSchema = z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']); + +export const registerAutomationNotificationPreferenceSchema = z.object({ + targetKind: notificationTargetKindSchema.default('client'), + targetId: z.string().trim().min(1).max(1000).optional(), + automation: automationNotificationPreferenceSchema, +}); + +export const registerIosTokenSchema = z.object({ + token: z.string().trim().min(1), + deviceId: z.string().trim().min(1).max(200).optional(), + enabled: z.boolean().default(true), +}); + +export const unregisterIosTokenSchema = z.object({ + token: z.string().trim().min(1), +}); + +export const registerWebPushSubscriptionSchema = z.object({ + subscription: z.object({ + endpoint: z.string().trim().url(), + expirationTime: z.number().nullable().optional(), + keys: z.object({ + p256dh: z.string().trim().min(1), + auth: z.string().trim().min(1), + }), + }), + deviceId: z.string().trim().min(1).max(200).optional(), + userAgent: z.string().trim().max(500).optional(), + enabled: z.boolean().default(true), +}); + +export const unregisterWebPushSubscriptionSchema = z.object({ + endpoint: z.string().trim().url(), +}); + +export const sendIosNotificationSchema = z.object({ + title: z.string().trim().min(1), + body: z.string().trim().min(1), + data: z.record(z.string(), z.string()).default({}), + threadId: z.string().trim().min(1).optional(), +}); + +type IosNotificationPayload = z.infer; +type WebPushSubscriptionPayload = z.infer['subscription']; +type AutomationNotificationPreference = z.infer; +type NotificationTargetKind = z.infer; +type NotificationPreferenceTarget = { + kind: NotificationTargetKind; + id: string; +}; + +type WebPushFailureDetail = { + endpoint: string; + statusCode?: number; + detail?: string; + code?: string; + attemptCount: number; +}; + +function buildScopedPwaNotificationTargetId(token: string, clientId: string) { + return [token.trim(), clientId.trim()].filter(Boolean).join('::client::'); +} + +let providerPromise: Promise | null = null; +let providerSignature: string | null = null; + +function normalizePrivateKey(privateKey: string) { + return privateKey.replace(/\\n/g, '\n'); +} + +async function loadPrivateKey(env: ReturnType) { + if (env.APNS_PRIVATE_KEY?.trim()) { + return normalizePrivateKey(env.APNS_PRIVATE_KEY.trim()); + } + + if (env.APNS_PRIVATE_KEY_PATH?.trim()) { + const file = await readFile(env.APNS_PRIVATE_KEY_PATH.trim(), 'utf8'); + return file.trim(); + } + + return null; +} + +function hasWebPushConfig(env: ReturnType) { + return Boolean( + env.WEB_PUSH_ENABLED && + env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() && + env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() && + env.WEB_PUSH_SUBJECT?.trim(), + ); +} + +function ensureWebPushConfigured(env: ReturnType) { + if (!hasWebPushConfig(env)) { + return false; + } + + webpush.setVapidDetails( + env.WEB_PUSH_SUBJECT, + env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim(), + env.WEB_PUSH_VAPID_PRIVATE_KEY!.trim(), + ); + + return true; +} + +function normalizeNotificationDetailText(text?: string | null) { + const normalized = String(text ?? '').trim(); + return normalized || undefined; +} + +function isChatNotificationPayload(payload: IosNotificationPayload) { + const category = String(payload.data?.category ?? '').trim().toLowerCase(); + const threadId = String(payload.threadId ?? '').trim().toLowerCase(); + return category === 'chat' || threadId.startsWith('chat:'); +} + +function isRetryableWebPushError(error: any) { + const statusCode = Number(error?.statusCode ?? 0); + + if ([408, 425, 429, 500, 502, 503, 504].includes(statusCode)) { + return true; + } + + const code = String(error?.code ?? '').trim().toUpperCase(); + return ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(code); +} + +async function waitForRetry(delayMs: number) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +async function sendWebPushWithRetry(subscription: WebPushSubscriptionPayload, payloadText: string) { + let lastError: unknown; + + for (let attempt = 1; attempt <= 2; attempt += 1) { + try { + await webpush.sendNotification(subscription, payloadText); + return { attemptCount: attempt }; + } catch (error) { + lastError = error; + + if (attempt >= 2 || !isRetryableWebPushError(error)) { + throw { + error, + attemptCount: attempt, + }; + } + + await waitForRetry(250 * attempt); + } + } + + throw { + error: lastError, + attemptCount: 2, + }; +} + +function buildProviderSignature(env: ReturnType) { + return [ + env.IOS_NOTIFICATION_ENABLED, + env.APNS_KEY_ID, + env.APNS_TEAM_ID, + env.APNS_BUNDLE_ID, + env.APNS_PRIVATE_KEY, + env.APNS_PRIVATE_KEY_PATH, + env.APNS_PRODUCTION, + ].join('|'); +} + +async function createProvider(env: ReturnType) { + if ( + !env.IOS_NOTIFICATION_ENABLED || + !env.APNS_KEY_ID || + !env.APNS_TEAM_ID || + !env.APNS_BUNDLE_ID + ) { + return null; + } + + const privateKey = await loadPrivateKey(env); + + if (!privateKey) { + return null; + } + + return new Provider({ + token: { + key: privateKey, + keyId: env.APNS_KEY_ID, + teamId: env.APNS_TEAM_ID, + }, + production: env.APNS_PRODUCTION, + }); +} + +async function getProvider() { + const env = getEnv(); + const signature = buildProviderSignature(env); + + if (signature !== providerSignature) { + providerSignature = signature; + providerPromise = null; + } + + if (!providerPromise) { + providerPromise = createProvider(env); + } + + return providerPromise; +} + +async function ensureNotificationTokenTable() { + const hasTable = await db.schema.hasTable(NOTIFICATION_TOKEN_TABLE); + + if (!hasTable) { + await db.schema.createTable(NOTIFICATION_TOKEN_TABLE, (table) => { + table.increments('id').primary(); + table.string('platform', 20).notNullable().defaultTo('ios'); + table.string('device_token', 255).notNullable().unique(); + table.string('device_id', 200).nullable(); + table.boolean('is_enabled').notNullable().defaultTo(true); + table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['platform', (table) => table.string('platform', 20).notNullable().defaultTo('ios')], + ['device_token', (table) => table.string('device_token', 255).notNullable()], + ['device_id', (table) => table.string('device_id', 200).nullable()], + ['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)], + [ + 'last_registered_at', + (table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()), + ], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(NOTIFICATION_TOKEN_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(NOTIFICATION_TOKEN_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +async function ensureWebPushSubscriptionTable() { + const hasTable = await db.schema.hasTable(WEB_PUSH_SUBSCRIPTION_TABLE); + + if (!hasTable) { + await db.schema.createTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => { + table.increments('id').primary(); + table.string('endpoint', 1000).notNullable().unique(); + table.jsonb('subscription_json').notNullable(); + table.string('device_id', 200).nullable(); + table.text('user_agent').nullable(); + table.boolean('is_enabled').notNullable().defaultTo(true); + table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['endpoint', (table) => table.string('endpoint', 1000).notNullable()], + ['subscription_json', (table) => table.jsonb('subscription_json').notNullable().defaultTo('{}')], + ['device_id', (table) => table.string('device_id', 200).nullable()], + ['user_agent', (table) => table.text('user_agent').nullable()], + ['is_enabled', (table) => table.boolean('is_enabled').notNullable().defaultTo(true)], + [ + 'last_registered_at', + (table) => table.timestamp('last_registered_at', { useTz: true }).notNullable().defaultTo(db.fn.now()), + ], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(WEB_PUSH_SUBSCRIPTION_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(WEB_PUSH_SUBSCRIPTION_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +async function ensureNotificationPreferenceTable() { + const hasTable = await db.schema.hasTable(NOTIFICATION_PREFERENCE_TABLE); + + if (!hasTable) { + await db.schema.createTable(NOTIFICATION_PREFERENCE_TABLE, (table) => { + table.increments('id').primary(); + table.string('target_kind', 40).notNullable(); + table.string('target_id', 1000).notNullable(); + table.jsonb('config_json').notNullable().defaultTo('{}'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['target_kind', 'target_id']); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['target_kind', (table) => table.string('target_kind', 40).notNullable().defaultTo('client')], + ['target_id', (table) => table.string('target_id', 1000).notNullable().defaultTo('')], + ['config_json', (table) => table.jsonb('config_json').notNullable().defaultTo('{}')], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(NOTIFICATION_PREFERENCE_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(NOTIFICATION_PREFERENCE_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function setupNotificationTables() { + await ensureNotificationTokenTable(); + await ensureWebPushSubscriptionTable(); + await ensureNotificationPreferenceTable(); + await ensureNotificationMessagesTable(); + + return { + ok: true, + tables: [NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE, NOTIFICATION_PREFERENCE_TABLE, 'notification_messages'], + }; +} + +export function getWebPushConfig() { + const env = getEnv(); + return { + enabled: hasWebPushConfig(env), + publicKey: hasWebPushConfig(env) ? env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim() : '', + }; +} + +export async function listIosNotificationTokens() { + await ensureNotificationTokenTable(); + + const rows = await db(NOTIFICATION_TOKEN_TABLE) + .where({ platform: 'ios' }) + .orderBy('updated_at', 'desc'); + + return rows.map((row) => ({ + id: row.id, + platform: row.platform, + token: row.device_token, + deviceId: row.device_id, + enabled: row.is_enabled, + lastRegisteredAt: row.last_registered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); +} + +export async function registerIosNotificationToken(payload: z.infer) { + await ensureNotificationTokenTable(); + + if (!payload.enabled) { + await unregisterIosNotificationToken(payload.token); + + return { + ok: true, + removed: true, + token: payload.token, + }; + } + + await db(NOTIFICATION_TOKEN_TABLE) + .insert({ + platform: 'ios', + device_token: payload.token, + device_id: payload.deviceId ?? null, + is_enabled: true, + last_registered_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .onConflict('device_token') + .merge({ + platform: 'ios', + device_id: payload.deviceId ?? null, + is_enabled: true, + last_registered_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + return { + ok: true, + token: payload.token, + }; +} + +function parseAutomationNotificationPreference(raw: unknown): AutomationNotificationPreference { + if (typeof raw === 'string') { + try { + return automationNotificationPreferenceSchema.parse(JSON.parse(raw)); + } catch { + return {}; + } + } + + return automationNotificationPreferenceSchema.parse(raw ?? {}); +} + +export async function getAutomationNotificationPreference( + targetId: string, + targetKind: NotificationTargetKind = 'client', +) { + const normalizedTargetId = targetId.trim(); + + if (!normalizedTargetId) { + return null; + } + + await ensureNotificationPreferenceTable(); + + const row = await db(NOTIFICATION_PREFERENCE_TABLE) + .where({ + target_kind: targetKind, + target_id: normalizedTargetId, + }) + .first(); + + if (!row) { + return null; + } + + return parseAutomationNotificationPreference(row.config_json); +} + +export async function upsertAutomationNotificationPreference( + payload: z.infer & { targetId: string }, +) { + const targetId = payload.targetId.trim(); + await ensureNotificationPreferenceTable(); + + await db(NOTIFICATION_PREFERENCE_TABLE) + .insert({ + target_kind: payload.targetKind, + target_id: targetId, + config_json: payload.automation, + updated_at: db.fn.now(), + }) + .onConflict(['target_kind', 'target_id']) + .merge({ + config_json: payload.automation, + updated_at: db.fn.now(), + }); + + return { + ok: true, + targetKind: payload.targetKind, + targetId, + automation: payload.automation, + }; +} + +export async function unregisterIosNotificationToken(token: string) { + await ensureNotificationTokenTable(); + + const deletedCount = await db(NOTIFICATION_TOKEN_TABLE) + .where({ + platform: 'ios', + device_token: token, + }) + .delete(); + + return { + ok: true, + removed: deletedCount > 0, + token, + }; +} + +export async function registerWebPushSubscription( + payload: z.infer, +) { + await ensureWebPushSubscriptionTable(); + + if (!payload.enabled) { + await unregisterWebPushSubscription(payload.subscription.endpoint); + + return { + ok: true, + removed: true, + endpoint: payload.subscription.endpoint, + }; + } + + await db(WEB_PUSH_SUBSCRIPTION_TABLE) + .insert({ + endpoint: payload.subscription.endpoint, + subscription_json: payload.subscription, + device_id: payload.deviceId ?? null, + user_agent: payload.userAgent ?? null, + is_enabled: true, + last_registered_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .onConflict('endpoint') + .merge({ + subscription_json: payload.subscription, + device_id: payload.deviceId ?? null, + user_agent: payload.userAgent ?? null, + is_enabled: true, + last_registered_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + if (payload.deviceId?.trim()) { + await db(WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ device_id: payload.deviceId.trim() }) + .whereNot({ endpoint: payload.subscription.endpoint }) + .delete(); + } + + return { + ok: true, + endpoint: payload.subscription.endpoint, + }; +} + +export async function unregisterWebPushSubscription(endpoint: string) { + await ensureWebPushSubscriptionTable(); + + const deletedCount = await db(WEB_PUSH_SUBSCRIPTION_TABLE).where({ endpoint }).delete(); + + return { + ok: true, + removed: deletedCount > 0, + endpoint, + }; +} + +async function getEnabledIosTokens() { + await ensureNotificationTokenTable(); + + const rows = await db(NOTIFICATION_TOKEN_TABLE) + .where({ + platform: 'ios', + is_enabled: true, + }) + .select('device_token', 'device_id'); + + return rows.map((row) => ({ + token: String(row.device_token), + deviceId: row.device_id ? String(row.device_id) : '', + })); +} + +async function getEnabledWebPushSubscriptions() { + await ensureWebPushSubscriptionTable(); + + const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ + is_enabled: true, + }) + .select('endpoint', 'subscription_json', 'device_id'); + + return rows.map((row) => ({ + endpoint: String(row.endpoint), + subscription: row.subscription_json as WebPushSubscriptionPayload, + deviceId: row.device_id ? String(row.device_id) : '', + })); +} + +async function removeInvalidIosTokens(tokens: string[]) { + if (!tokens.length) { + return; + } + + await db(NOTIFICATION_TOKEN_TABLE) + .whereIn('device_token', tokens) + .delete(); +} + +async function removeInvalidWebPushSubscriptions(endpoints: string[]) { + if (!endpoints.length) { + return; + } + + await db(WEB_PUSH_SUBSCRIPTION_TABLE) + .whereIn('endpoint', endpoints) + .delete(); +} + +function shouldNotifyAutomationEvent( + automation: AutomationNotificationPreference | null | undefined, + eventType: string, +) { + if (eventType === 'work-started') { + return automation?.notifyOnAutomationStart ?? true; + } + + if (eventType === 'work-progress') { + return automation?.notifyOnAutomationProgress ?? true; + } + + if ( + eventType === 'work-completed' || + eventType === 'work-noop-complete' || + eventType === 'development-completed' || + eventType === 'plan-completed' + ) { + return automation?.notifyOnAutomationCompletion ?? true; + } + + if (eventType === 'release-merged') { + return automation?.notifyOnAutomationRelease ?? true; + } + + if (eventType === 'main-merged') { + return automation?.notifyOnAutomationMain ?? true; + } + + if ( + eventType === 'branch-failed' || + eventType === 'work-failed' || + eventType === 'release-failed' || + eventType === 'main-failed' + ) { + return automation?.notifyOnAutomationFailure ?? true; + } + + if (eventType === 'plan-restarted') { + return automation?.notifyOnAutomationRestart ?? true; + } + + if (eventType === 'issue-resolved') { + return automation?.notifyOnAutomationIssueResolved ?? true; + } + + return true; +} + +async function isNotificationRecipientAllowed( + preferenceTargets: NotificationPreferenceTarget[], + payload: IosNotificationPayload, +) { + const eventType = payload.data.eventType?.trim(); + + if (!eventType) { + return true; + } + + for (const target of preferenceTargets) { + if (!target.id.trim()) { + continue; + } + + const automation = await getAutomationNotificationPreference(target.id, target.kind); + + if (automation) { + return shouldNotifyAutomationEvent(automation, eventType); + } + } + + return true; +} + +export async function sendIosNotifications(payload: IosNotificationPayload) { + const env = getEnv(); + const provider = await getProvider(); + + if (!provider || !env.APNS_BUNDLE_ID) { + return { + ok: false, + skipped: true, + reason: 'APNs ์„ค์ •์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.', + sentCount: 0, + failedCount: 0, + }; + } + + const tokenRows = await getEnabledIosTokens(); + const tokens = ( + await Promise.all( + tokenRows.map(async (row) => ({ + token: row.token, + allowed: await isNotificationRecipientAllowed( + [ + { kind: 'ios-token-client', id: buildScopedPwaNotificationTargetId(row.token, row.deviceId) }, + { kind: 'ios-token', id: row.token }, + { kind: 'client', id: row.deviceId }, + ], + payload, + ), + })), + ) + ) + .filter((row) => row.allowed) + .map((row) => row.token); + + if (!tokens.length) { + return { + ok: true, + skipped: true, + reason: '๋“ฑ๋ก๋œ iOS ์•Œ๋ฆผ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค.', + sentCount: 0, + failedCount: 0, + }; + } + + const notification = new Notification(); + notification.topic = env.APNS_BUNDLE_ID; + notification.pushType = 'alert'; + notification.priority = 10; + notification.expiry = Math.floor(Date.now() / 1000) + 3600; + notification.alert = { + title: payload.title, + body: payload.body, + }; + notification.sound = 'default'; + notification.badge = 1; + notification.payload = payload.data; + if (payload.threadId) { + notification.threadId = payload.threadId; + } + + const response = await provider.send(notification, tokens); + const invalidTokens = response.failed + .map((result) => result.device) + .filter((device): device is string => Boolean(device)); + + await removeInvalidIosTokens(invalidTokens); + + return { + ok: response.failed.length === 0, + skipped: false, + sentCount: response.sent.length, + failedCount: response.failed.length, + invalidTokens, + }; +} + +async function sendWebPushNotifications(payload: IosNotificationPayload) { + const env = getEnv(); + if (!ensureWebPushConfigured(env)) { + return { + ok: false, + skipped: true, + reason: 'Web Push ์„ค์ •์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.', + sentCount: 0, + failedCount: 0, + }; + } + + const subscriptions = ( + await Promise.all( + (await getEnabledWebPushSubscriptions()).map(async (row) => ({ + ...row, + allowed: await isNotificationRecipientAllowed( + [ + { kind: 'web-endpoint', id: row.endpoint }, + { kind: 'client', id: row.deviceId }, + ], + payload, + ), + })), + ) + ).filter((row) => row.allowed); + + if (!subscriptions.length) { + return { + ok: true, + skipped: true, + reason: '๋“ฑ๋ก๋œ Web Push ๊ตฌ๋…์ด ์—†์Šต๋‹ˆ๋‹ค.', + sentCount: 0, + failedCount: 0, + }; + } + + const payloadText = JSON.stringify({ + title: payload.title, + body: payload.body, + data: payload.data, + threadId: payload.threadId, + }); + const preserveSubscriptions = isChatNotificationPayload(payload); + + const invalidEndpoints: string[] = []; + let sentCount = 0; + let failedCount = 0; + const failures: WebPushFailureDetail[] = []; + + await Promise.all( + subscriptions.map(async ({ endpoint, subscription }) => { + try { + await sendWebPushWithRetry(subscription, payloadText); + sentCount += 1; + } catch (error: any) { + const deliveryError = error?.error ?? error; + const attemptCount = Number(error?.attemptCount ?? 1); + failedCount += 1; + const statusCode = Number(deliveryError?.statusCode ?? 0); + const detail = + normalizeNotificationDetailText(deliveryError?.body) ?? normalizeNotificationDetailText(deliveryError?.message); + const code = normalizeNotificationDetailText(deliveryError?.code); + + if (!preserveSubscriptions && (statusCode === 404 || statusCode === 410)) { + invalidEndpoints.push(endpoint); + } + + failures.push({ + endpoint, + statusCode: statusCode || undefined, + detail, + code, + attemptCount, + }); + } + }), + ); + + if (!preserveSubscriptions) { + await removeInvalidWebPushSubscriptions(invalidEndpoints); + } + + if (failures.length) { + console.warn( + '[notification-service] web push delivery failed', + JSON.stringify({ + failedCount, + preserveSubscriptions, + invalidEndpointCount: invalidEndpoints.length, + failures, + }), + ); + } + + return { + ok: failedCount === 0, + skipped: false, + sentCount, + failedCount, + invalidEndpoints, + }; +} + +export async function sendNotifications(payload: IosNotificationPayload) { + const [ios, web] = await Promise.all([ + sendIosNotifications(payload), + sendWebPushNotifications(payload), + ]); + + return { + ok: ios.ok || web.ok, + ios, + web, + }; +} + +export async function shutdownNotificationProvider() { + const provider = await getProvider(); + provider?.shutdown(); + providerPromise = null; +} diff --git a/etc/servers/work-server/src/services/plan-notification-policy.ts b/etc/servers/work-server/src/services/plan-notification-policy.ts new file mode 100755 index 0000000..b272fe2 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-notification-policy.ts @@ -0,0 +1,3 @@ +export function shouldNotifyPlanRestart(result: { didScheduleRetry?: boolean } | null | undefined) { + return Boolean(result?.didScheduleRetry); +} diff --git a/etc/servers/work-server/src/services/plan-notification-service.ts b/etc/servers/work-server/src/services/plan-notification-service.ts new file mode 100755 index 0000000..dbddba4 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-notification-service.ts @@ -0,0 +1,114 @@ +import { sendNotifications } from './notification-service.js'; +import type { AppConfigSnapshot } from './app-config-service.js'; +import { getPlanItemById } from './plan-service.js'; + +function buildPlanNotificationTargetUrl(planId: number, workId: string | null | undefined) { + const targetUrl = new URL('https://sm-home.cloud/'); + + targetUrl.searchParams.set('topMenu', 'plans'); + targetUrl.searchParams.set('planId', String(planId)); + + if (workId?.trim()) { + targetUrl.searchParams.set('workId', workId.trim()); + } + + return targetUrl.toString(); +} + +export function shouldNotifyPlanEventType( + automation: AppConfigSnapshot['automation'] | undefined, + eventType: string, +) { + if (eventType === 'work-started') { + return automation?.notifyOnAutomationStart ?? true; + } + + if (eventType === 'work-progress') { + return automation?.notifyOnAutomationProgress ?? true; + } + + if ( + eventType === 'work-completed' || + eventType === 'work-noop-complete' || + eventType === 'development-completed' || + eventType === 'plan-completed' + ) { + return automation?.notifyOnAutomationCompletion ?? true; + } + + if (eventType === 'release-merged') { + return automation?.notifyOnAutomationRelease ?? true; + } + + if (eventType === 'main-merged') { + return automation?.notifyOnAutomationMain ?? true; + } + + if ( + eventType === 'branch-failed' || + eventType === 'work-failed' || + eventType === 'release-failed' || + eventType === 'main-failed' + ) { + return automation?.notifyOnAutomationFailure ?? true; + } + + if (eventType === 'plan-restarted') { + return automation?.notifyOnAutomationRestart ?? true; + } + + if (eventType === 'issue-resolved') { + return automation?.notifyOnAutomationIssueResolved ?? true; + } + + return true; +} + +export async function shouldSendPlanNotification(eventType: string) { + void eventType; + return true; +} + +export function buildPlanNotificationData(planId: number, workId: string | null | undefined, eventType: string) { + return { + category: 'automation', + planId: String(planId), + workId: String(workId ?? ''), + eventType, + notificationScope: 'automation', + targetUrl: buildPlanNotificationTargetUrl(planId, workId), + notificationKey: `plan:${planId}`, + }; +} + +export async function notifyPlanEvent( + planId: number, + title: string, + body: string, + eventType: string, +) { + if (!(await shouldSendPlanNotification(eventType))) { + return { + ok: true, + skipped: true, + reason: '์•ฑ ์„ค์ •์—์„œ ์ด ์•Œ๋ฆผ ํ•ญ๋ชฉ์ด ๊บผ์ ธ ์žˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + const item = await getPlanItemById(planId); + + if (!item) { + return { + ok: false, + skipped: true, + reason: '์ž‘์—… ํ•ญ๋ชฉ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', + }; + } + + return sendNotifications({ + title, + body, + threadId: `plan-${planId}`, + data: buildPlanNotificationData(planId, String(item.workId), eventType), + }); +} diff --git a/etc/servers/work-server/src/services/plan-policy.test.ts b/etc/servers/work-server/src/services/plan-policy.test.ts new file mode 100755 index 0000000..604fff5 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-policy.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPlanNotificationData } from './plan-notification-service.js'; +import { shouldNotifyPlanRestart } from './plan-notification-policy.js'; +import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js'; +import { issueActionSchema } from './plan-service.js'; + +test('shouldTriggerRetryFromActionNote detects missing-fix and verification follow-up requests', () => { + assert.equal(shouldTriggerRetryFromActionNote('๋ˆ„๋ฝ๋œ ๊ฑฐ ๋‹ค์‹œ ๊ณ ์ณ์„œ ํ…Œ์ŠคํŠธํ•ด ์ค˜'), true); + assert.equal(shouldTriggerRetryFromActionNote('๋น ์ง„ ๋‚ด์šฉ ๋ณด์™„ํ•˜๊ณ  ๊ฒ€์ฆํ•ด์ค˜'), true); +}); + +test('shouldTriggerRetryFromActionNote ignores simple status comments', () => { + assert.equal(shouldTriggerRetryFromActionNote('ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ฉ”๋ชจ๋งŒ ๋‚จ๊น๋‹ˆ๋‹ค.'), false); +}); + +test('issueActionSchema defaults retry to false', () => { + assert.deepEqual( + issueActionSchema.parse({ + actionNote: '์›์ธ ๋ถ„์„๊ณผ ์กฐ์น˜ ๋‚ด์šฉ์„ ๋‚จ๊น๋‹ˆ๋‹ค.', + }), + { + actionNote: '์›์ธ ๋ถ„์„๊ณผ ์กฐ์น˜ ๋‚ด์šฉ์„ ๋‚จ๊น๋‹ˆ๋‹ค.', + resolve: false, + retry: false, + }, + ); +}); + +test('shouldNotifyPlanRestart only allows restart alerts when a retry was actually scheduled', () => { + assert.equal( + shouldNotifyPlanRestart({ + didScheduleRetry: false, + }), + false, + ); + assert.equal( + shouldNotifyPlanRestart({ + didScheduleRetry: true, + }), + true, + ); + assert.equal(shouldNotifyPlanRestart(null), false); +}); + +test('buildPlanNotificationData uses stable task key per plan', () => { + assert.deepEqual(buildPlanNotificationData(17, 'WK-123', 'plan-restarted'), { + planId: '17', + workId: 'WK-123', + eventType: 'plan-restarted', + targetUrl: 'https://sm-home.cloud/?topMenu=plans&planId=17&workId=WK-123', + notificationKey: 'plan:17', + }); +}); diff --git a/etc/servers/work-server/src/services/plan-retry-policy.ts b/etc/servers/work-server/src/services/plan-retry-policy.ts new file mode 100755 index 0000000..9113d15 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-retry-policy.ts @@ -0,0 +1,11 @@ +export function shouldTriggerRetryFromActionNote(actionNote: string) { + const text = String(actionNote ?? '').trim(); + + if (!text) { + return false; + } + + return /(์žฌ์ฒ˜๋ฆฌ|๋‹ค์‹œ|๋ˆ„๋ฝ|๋น ์ง„|๋นผ๋จน|๋ณด์™„|์กฐ์น˜ํ•ด|์ฒ˜๋ฆฌํ•ด|ํ•ด๊ฒฐํ•ด|๊ณ ์ณ|๋ฐ˜์˜ํ•ด|์ˆ˜์ •ํ•ด|์‹œ๋„ํ•ด|์ง„ํ–‰ํ•ด|ํ…Œ์ŠคํŠธํ•ด|๊ฒ€์ฆํ•ด|๋ถ€ํƒ)/.test( + text, + ); +} diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts new file mode 100755 index 0000000..c90e591 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-schedule-service.ts @@ -0,0 +1,540 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; +import { + createCompletedPlanExecutionLogItem, + createPlanActionHistory, + createPlanItem, + ensurePlanTable, + normalizePlanAutomationType, + planAutomationTypeSchema, +} from './plan-service.js'; +import { getKstNowParts } from './worklog-automation-utils.js'; +import { registerErrorLogBoardPosts } from './error-log-plan-registration-service.js'; + +export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; +const scheduleModes = ['interval', 'daily'] as const; +const repeatIntervalUnits = ['minute', 'hour', 'day', 'week', 'month'] as const; +const DEFAULT_DAILY_RUN_TIME = '09:00'; + +export const createPlanScheduledTaskSchema = z.object({ + workId: z.string().trim().optional().default('๋ฐ˜๋ณต์ž‘์—…'), + note: z.string().default(''), + automationType: z.preprocess((value) => value, planAutomationTypeSchema.default('none')), + releaseTarget: z.string().trim().min(1).default('release'), + jangsingProcessingRequired: z.boolean().default(true), + autoDeployToMain: z.boolean().default(true), + enabled: z.boolean().default(true), + immediateRunEnabled: z.boolean().default(true), + scheduleMode: z.enum(scheduleModes).default('interval'), + repeatIntervalValue: z.coerce.number().int().min(1).max(525600).default(60), + repeatIntervalUnit: z.enum(repeatIntervalUnits).default('minute'), + repeatIntervalMinutes: z.coerce.number().int().min(1).max(525600).optional(), + dailyRunTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).default(DEFAULT_DAILY_RUN_TIME), +}); + +export const updatePlanScheduledTaskSchema = createPlanScheduledTaskSchema.partial(); + +function normalizeScheduledWorkId(value?: string | null) { + const workId = String(value ?? '').trim(); + const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); + + if (!workId || normalized === '์ž‘์—…id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { + return '๋ฐ˜๋ณต์ž‘์—…'; + } + + return workId; +} + +function normalizeRepeatIntervalMinutes(value: unknown) { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + return 60; + } + + return Math.min(525600, Math.max(1, Math.round(numericValue))); +} + +function normalizeRepeatIntervalValue(value: unknown) { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + return 60; + } + + return Math.min(525600, Math.max(1, Math.round(numericValue))); +} + +function normalizeRepeatIntervalUnit(value: unknown): (typeof repeatIntervalUnits)[number] { + return repeatIntervalUnits.includes(value as (typeof repeatIntervalUnits)[number]) + ? (value as (typeof repeatIntervalUnits)[number]) + : 'minute'; +} + +function normalizeScheduleMode(value: unknown): (typeof scheduleModes)[number] { + return scheduleModes.includes(value as (typeof scheduleModes)[number]) + ? (value as (typeof scheduleModes)[number]) + : 'interval'; +} + +function normalizeDailyRunTime(value: unknown) { + return typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value) + ? value + : DEFAULT_DAILY_RUN_TIME; +} + +function toRepeatIntervalMinutes(value: unknown, unit: unknown) { + const repeatIntervalValue = normalizeRepeatIntervalValue(value); + const repeatIntervalUnit = normalizeRepeatIntervalUnit(unit); + + if (repeatIntervalUnit === 'day') { + return repeatIntervalValue * 24 * 60; + } + + if (repeatIntervalUnit === 'week') { + return repeatIntervalValue * 7 * 24 * 60; + } + + if (repeatIntervalUnit === 'month') { + return repeatIntervalValue * 30 * 24 * 60; + } + + if (repeatIntervalUnit === 'hour') { + return repeatIntervalValue * 60; + } + + return repeatIntervalValue; +} + +function normalizeBoolean(value: unknown, fallback: boolean) { + if (typeof value === 'boolean') { + return value; + } + + if (value === 0 || value === '0' || value === 'false') { + return false; + } + + if (value === 1 || value === '1' || value === 'true') { + return true; + } + + return fallback; +} + +function formatScheduleWorkId(workId: string, date: Date) { + const timestamp = [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + String(date.getHours()).padStart(2, '0'), + String(date.getMinutes()).padStart(2, '0'), + ].join(''); + + return `${normalizeScheduledWorkId(workId)}-${timestamp}`; +} + +function getKstDateKey(value: unknown) { + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(String(value)); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return getKstNowParts(date).dateKey; +} + +function isDailyScheduleDue(row: Record, now: Date) { + const nowParts = getKstNowParts(now); + const [hours, minutes] = normalizeDailyRunTime(row.daily_run_time).split(':').map((value) => Number(value)); + const scheduledMinutesOfDay = hours * 60 + minutes; + return nowParts.minutesOfDay >= scheduledMinutesOfDay && getKstDateKey(row.last_registered_at) !== nowParts.dateKey; +} + +function isIntervalScheduleDue(row: Record, now: Date) { + const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; + const intervalBaseAt = lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) + ? lastRegisteredAt + : new Date(String(row.created_at ?? now.toISOString())); + + if (Number.isNaN(intervalBaseAt.getTime())) { + return true; + } + + const repeatIntervalMinutes = normalizeRepeatIntervalMinutes( + row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), + ); + return intervalBaseAt.getTime() <= now.getTime() - repeatIntervalMinutes * 60 * 1000; +} + +function isScheduleDue(row: Record, now: Date) { + if (!row.last_registered_at && normalizeBoolean(row.immediate_run_enabled, true)) { + return true; + } + + return normalizeScheduleMode(row.schedule_mode) === 'daily' + ? isDailyScheduleDue(row, now) + : isIntervalScheduleDue(row, now); +} + +export function mapPlanScheduledTaskRow(row: Record) { + return { + id: row.id, + workId: row.work_id, + note: row.note, + automationType: normalizePlanAutomationType(row.automation_type), + releaseTarget: row.release_target, + jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), + autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), + enabled: Boolean(row.enabled ?? true), + immediateRunEnabled: normalizeBoolean(row.immediate_run_enabled, true), + scheduleMode: normalizeScheduleMode(row.schedule_mode), + repeatIntervalValue: Number(row.repeat_interval_value ?? row.repeat_interval_minutes ?? 60), + repeatIntervalUnit: normalizeRepeatIntervalUnit(row.repeat_interval_unit), + repeatIntervalMinutes: normalizeRepeatIntervalMinutes( + row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value ?? 60, row.repeat_interval_unit), + ), + dailyRunTime: normalizeDailyRunTime(row.daily_run_time), + lastRegisteredAt: row.last_registered_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +async function ensurePlanScheduledTaskColumn( + columnName: string, + addColumn: (table: any) => void, +) { + const hasColumn = await db.schema.hasColumn(PLAN_SCHEDULED_TASK_TABLE, columnName); + + if (hasColumn) { + return; + } + + await db.schema.alterTable(PLAN_SCHEDULED_TASK_TABLE, (table) => { + addColumn(table); + }); +} + +export async function ensurePlanScheduledTaskTable() { + const exists = await db.schema.hasTable(PLAN_SCHEDULED_TASK_TABLE); + + if (!exists) { + await db.schema.createTable(PLAN_SCHEDULED_TASK_TABLE, (table) => { + table.increments('id').primary(); + table.string('work_id', 120).notNullable().defaultTo('๋ฐ˜๋ณต์ž‘์—…'); + table.text('note').notNullable().defaultTo(''); + table.string('automation_type', 40).notNullable().defaultTo('none'); + table.string('release_target', 120).notNullable().defaultTo('release'); + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + table.boolean('enabled').notNullable().defaultTo(true); + table.boolean('immediate_run_enabled').notNullable().defaultTo(true); + table.string('schedule_mode', 20).notNullable().defaultTo('interval'); + table.integer('repeat_interval_value').notNullable().defaultTo(60); + table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + table.timestamp('last_registered_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + return; + } + + await ensurePlanScheduledTaskColumn('release_target', (table) => { + table.string('release_target', 120).notNullable().defaultTo('release'); + }); + await ensurePlanScheduledTaskColumn('automation_type', (table) => { + table.string('automation_type', 40).notNullable().defaultTo('none'); + }); + await ensurePlanScheduledTaskColumn('jangsing_processing_required', (table) => { + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + }); + await ensurePlanScheduledTaskColumn('auto_deploy_to_main', (table) => { + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + }); + await ensurePlanScheduledTaskColumn('enabled', (table) => { + table.boolean('enabled').notNullable().defaultTo(true); + }); + await ensurePlanScheduledTaskColumn('immediate_run_enabled', (table) => { + table.boolean('immediate_run_enabled').notNullable().defaultTo(true); + }); + await ensurePlanScheduledTaskColumn('schedule_mode', (table) => { + table.string('schedule_mode', 20).notNullable().defaultTo('interval'); + }); + await ensurePlanScheduledTaskColumn('repeat_interval_value', (table) => { + table.integer('repeat_interval_value').notNullable().defaultTo(60); + }); + await ensurePlanScheduledTaskColumn('repeat_interval_unit', (table) => { + table.string('repeat_interval_unit', 20).notNullable().defaultTo('minute'); + }); + await ensurePlanScheduledTaskColumn('repeat_interval_minutes', (table) => { + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + }); + await ensurePlanScheduledTaskColumn('daily_run_time', (table) => { + table.string('daily_run_time', 5).notNullable().defaultTo(DEFAULT_DAILY_RUN_TIME); + }); + await ensurePlanScheduledTaskColumn('last_registered_at', (table) => { + table.timestamp('last_registered_at', { useTz: true }).nullable(); + }); + await ensurePlanScheduledTaskColumn('created_at', (table) => { + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + await ensurePlanScheduledTaskColumn('updated_at', (table) => { + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ automation_type: 'plan_registration' }) + .update({ automation_type: 'plan' }); + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ automation_type: 'general_development' }) + .update({ automation_type: 'auto_worker' }); + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ repeat_interval_unit: 'minute' }) + .update({ + repeat_interval_value: db.raw('repeat_interval_minutes'), + }); +} + +export async function listPlanScheduledTasks() { + await ensurePlanScheduledTaskTable(); + + return db(PLAN_SCHEDULED_TASK_TABLE).select('*').orderBy('id', 'desc'); +} + +export async function getPlanScheduledTaskById(id: number) { + await ensurePlanScheduledTaskTable(); + + return db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); +} + +export async function createPlanScheduledTask(payload: z.infer) { + await ensurePlanScheduledTaskTable(); + const scheduleMode = normalizeScheduleMode(payload.scheduleMode); + const repeatIntervalValue = normalizeRepeatIntervalValue(payload.repeatIntervalValue); + const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit); + + const rows = await db(PLAN_SCHEDULED_TASK_TABLE) + .insert({ + work_id: normalizeScheduledWorkId(payload.workId), + note: payload.note, + automation_type: normalizePlanAutomationType(payload.automationType), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + enabled: payload.enabled, + immediate_run_enabled: payload.immediateRunEnabled, + schedule_mode: scheduleMode, + repeat_interval_value: repeatIntervalValue, + repeat_interval_unit: repeatIntervalUnit, + repeat_interval_minutes: normalizeRepeatIntervalMinutes( + payload.repeatIntervalMinutes ?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit), + ), + daily_run_time: normalizeDailyRunTime(payload.dailyRunTime), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; +} + +export async function updatePlanScheduledTask(id: number, payload: z.infer) { + await ensurePlanScheduledTaskTable(); + + const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + const scheduleMode = normalizeScheduleMode(payload.scheduleMode ?? currentRow.schedule_mode); + const repeatIntervalValue = normalizeRepeatIntervalValue( + payload.repeatIntervalValue ?? currentRow.repeat_interval_value ?? currentRow.repeat_interval_minutes ?? 60, + ); + const repeatIntervalUnit = normalizeRepeatIntervalUnit(payload.repeatIntervalUnit ?? currentRow.repeat_interval_unit); + + const rows = await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id }) + .update({ + work_id: payload.workId === undefined ? currentRow.work_id : normalizeScheduledWorkId(payload.workId), + note: payload.note ?? currentRow.note, + automation_type: normalizePlanAutomationType(payload.automationType ?? currentRow.automation_type), + release_target: payload.releaseTarget ?? currentRow.release_target ?? 'release', + jangsing_processing_required: + payload.jangsingProcessingRequired ?? currentRow.jangsing_processing_required ?? true, + auto_deploy_to_main: payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true, + enabled: payload.enabled ?? currentRow.enabled ?? true, + immediate_run_enabled: payload.immediateRunEnabled ?? currentRow.immediate_run_enabled ?? true, + schedule_mode: scheduleMode, + repeat_interval_value: repeatIntervalValue, + repeat_interval_unit: repeatIntervalUnit, + repeat_interval_minutes: normalizeRepeatIntervalMinutes( + payload.repeatIntervalMinutes + ?? toRepeatIntervalMinutes(repeatIntervalValue, repeatIntervalUnit) + ?? currentRow.repeat_interval_minutes + ?? 60, + ), + daily_run_time: normalizeDailyRunTime(payload.dailyRunTime ?? currentRow.daily_run_time), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0] ?? null; +} + +export async function deletePlanScheduledTask(id: number) { + await ensurePlanScheduledTaskTable(); + + const currentRow = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + await db(PLAN_SCHEDULED_TASK_TABLE).where({ id }).delete(); + + return currentRow; +} + +export async function registerDuePlanScheduledTasks(now = new Date()) { + await ensurePlanTable(); + await ensurePlanScheduledTaskTable(); + + const rows = await db(PLAN_SCHEDULED_TASK_TABLE).where({ enabled: true }).orderBy('id', 'asc'); + const registered = []; + + for (const row of rows.filter((item) => isScheduleDue(item, now))) { + const registration = await registerPlanScheduledTaskRow(row, now); + + if (registration.createdPlan || registration.createdBoardPosts.length > 0) { + registered.push(registration); + } + } + + return registered; +} + +async function registerPlanScheduledTaskRow(row: Record, now: Date) { + if (normalizePlanAutomationType(row.automation_type) === 'plan') { + const rangeEnd = now; + const lastRegisteredAt = row.last_registered_at ? new Date(String(row.last_registered_at)) : null; + const rangeStart = + lastRegisteredAt && !Number.isNaN(lastRegisteredAt.getTime()) + ? lastRegisteredAt + : new Date(now.getTime() - 24 * 60 * 60 * 1000); + const registration = await registerErrorLogBoardPosts({ + rangeStart, + rangeEnd, + }); + const executionLogNoteLines = [ + `Plan ์Šค์ผ€์ค„ #${row.id} ์‹คํ–‰ ์ด๋ ฅ์ž…๋‹ˆ๋‹ค.`, + `์กฐํšŒ ๊ตฌ๊ฐ„: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`, + `์‹ ๊ทœ ๊ฒŒ์‹œ๊ธ€ ๋“ฑ๋ก: ${registration.createdPosts.length}๊ฑด`, + `์ค‘๋ณต ์ œ์™ธ: ${registration.skippedPosts.length}๊ฑด`, + ]; + + if (registration.createdPosts.length > 0) { + executionLogNoteLines.push(''); + executionLogNoteLines.push('๋“ฑ๋ก๋œ ๊ฒŒ์‹œ๊ธ€:'); + executionLogNoteLines.push( + ...registration.createdPosts.map((post) => `- ๊ฒŒ์‹œ๊ธ€ #${post.postId} ${post.workId} (${post.count}๊ฑด)`), + ); + } + + if (registration.skippedPosts.length > 0) { + executionLogNoteLines.push(''); + executionLogNoteLines.push('์ œ์™ธ๋œ ํ•ญ๋ชฉ:'); + executionLogNoteLines.push( + ...registration.skippedPosts.map((post) => `- ${post.workId}: ${post.reason}`), + ); + } + + const executionLog = await createCompletedPlanExecutionLogItem({ + workId: formatScheduleWorkId(String(row.work_id ?? '๋ฐ˜๋ณต์ž‘์—…'), now), + note: executionLogNoteLines.join('\n'), + automationType: 'plan', + releaseTarget: String(row.release_target ?? 'release'), + jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), + autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), + repeatRequestEnabled: false, + repeatIntervalMinutes: normalizeRepeatIntervalMinutes( + row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), + ), + }); + + await createPlanActionHistory( + Number(executionLog.id), + '์Šค์ผ€์ค„๋“ฑ๋ก', + `Plan ์Šค์ผ€์ค„ #${row.id} ์‹คํ–‰ ์ด๋ ฅ์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ); + await createPlanActionHistory( + Number(executionLog.id), + '์™„๋ฃŒ์ฒ˜๋ฆฌ', + registration.createdPosts.length > 0 + ? `Plan ๊ฒŒ์‹œํŒ ๊ธ€ ${registration.createdPosts.length}๊ฑด์„ ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.` + : '๋“ฑ๋กํ•  ์‹ ๊ทœ Plan ๊ฒŒ์‹œํŒ ๊ธ€์ด ์—†์—ˆ์Šต๋‹ˆ๋‹ค.', + ); + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + last_registered_at: now, + updated_at: db.fn.now(), + }); + + return { + createdPlan: executionLog, + createdBoardPosts: registration.createdPosts, + }; + } + + const repeatIntervalMinutes = normalizeRepeatIntervalMinutes( + row.repeat_interval_minutes ?? toRepeatIntervalMinutes(row.repeat_interval_value, row.repeat_interval_unit), + ); + const createdPlan = await createPlanItem({ + workId: formatScheduleWorkId(String(row.work_id ?? '๋ฐ˜๋ณต์ž‘์—…'), now), + note: String(row.note ?? ''), + automationType: normalizePlanAutomationType(row.automation_type), + releaseTarget: String(row.release_target ?? 'release'), + jangsingProcessingRequired: Boolean(row.jangsing_processing_required ?? true), + autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), + repeatRequestEnabled: false, + repeatIntervalMinutes, + }); + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: row.id }) + .update({ + last_registered_at: now, + updated_at: db.fn.now(), + }); + await createPlanActionHistory( + Number(createdPlan.id), + '์Šค์ผ€์ค„๋“ฑ๋ก', + `Plan ์Šค์ผ€์ค„ #${row.id} ๋ฐ˜๋ณต ์ž‘์—…์—์„œ ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ); + + return { + createdPlan, + createdBoardPosts: [], + }; +} + +export async function registerPlanScheduledTaskNow(id: number, now = new Date()) { + await ensurePlanTable(); + await ensurePlanScheduledTaskTable(); + + const row = await db(PLAN_SCHEDULED_TASK_TABLE).where({ id, enabled: true }).first(); + + if (!row || !isScheduleDue(row, now)) { + return null; + } + + return registerPlanScheduledTaskRow(row, now); +} diff --git a/etc/servers/work-server/src/services/plan-service.test.ts b/etc/servers/work-server/src/services/plan-service.test.ts new file mode 100755 index 0000000..bb1cddf --- /dev/null +++ b/etc/servers/work-server/src/services/plan-service.test.ts @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildPlanBranchName, + filterRetryWorklogEvidencePaths, + listPlanQuerySchema, + maskPlanNote, + normalizeChangedFiles, + shouldResumePlanDevelopmentFromIssueAction, +} from './plan-service.js'; + +test('maskPlanNote masks note like the requested preview format', () => { + assert.equal(maskPlanNote('ํ…Œ์ŠคํŠธ ์ž‘์—… ์‹คํ–‰'), 'ํ…Œ** ์ž‘์—… *ํ–‰'); +}); + +test('maskPlanNote collapses spaces and masks single-word notes', () => { + assert.equal(maskPlanNote(' ๊ธด๊ธ‰๋ฉ”๋ชจ '), '๊ธด***'); + assert.equal(maskPlanNote('๊ฐ€'), '*'); +}); + +test('normalizeChangedFiles removes blanks and duplicate paths while preserving order', () => { + assert.deepEqual(normalizeChangedFiles([' a.ts ', '', 'a.ts', 'b.ts', ' ', 'b.ts']), ['a.ts', 'b.ts']); +}); + +test('filterRetryWorklogEvidencePaths keeps non-worklog files and removes repeated worklog artifacts', () => { + const changedFiles = [ + 'docs/worklogs/2026-04-07.md', + 'docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png', + 'src/features/planBoard/PlanBoardPage.tsx', + 'docs/assets/worklogs/2026-04-07/run.log', + ]; + const existingChangedFilesList = [ + ['docs/worklogs/2026-04-07.md', 'docs/assets/worklogs/2026-04-07/feature-docs-worklogs.png'], + ]; + + assert.deepEqual(filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList), [ + 'src/features/planBoard/PlanBoardPage.tsx', + 'docs/assets/worklogs/2026-04-07/run.log', + ]); +}); + +test('shouldResumePlanDevelopmentFromIssueAction only resumes release-complete items when retry is requested', () => { + assert.equal(shouldResumePlanDevelopmentFromIssueAction('๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', true), true); + assert.equal(shouldResumePlanDevelopmentFromIssueAction('๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', false), false); + assert.equal(shouldResumePlanDevelopmentFromIssueAction('์ž‘์—…์ค‘', true), false); +}); + +test('buildPlanBranchName uses hotfix prefix for auto-worklog plans and feature prefix otherwise', () => { + assert.equal(buildPlanBranchName('auto-worklog-2026-04-09', 187), 'hotfix/plan-187-auto-worklog-2026-04-09'); + assert.equal(buildPlanBranchName('new-play-ground', 182), 'feature/plan-182-new-play-ground'); +}); + +test('listPlanQuerySchema accepts legacy filter aliases without throwing', () => { + assert.deepEqual(listPlanQuerySchema.parse({ status: 'in-progress' }), { + status: undefined, + }); + assert.deepEqual(listPlanQuerySchema.parse({ status: 'done' }), { + status: undefined, + }); + assert.deepEqual(listPlanQuerySchema.parse({ status: 'error' }), { + status: undefined, + }); + assert.deepEqual(listPlanQuerySchema.parse({ status: 'all' }), { + status: undefined, + }); +}); + +test('listPlanQuerySchema keeps exact plan statuses and rejects unknown values', () => { + assert.deepEqual(listPlanQuerySchema.parse({ status: '์ž‘์—…์ค‘' }), { + status: '์ž‘์—…์ค‘', + }); + assert.throws(() => listPlanQuerySchema.parse({ status: 'processing' })); +}); diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts new file mode 100755 index 0000000..6d4baf2 --- /dev/null +++ b/etc/servers/work-server/src/services/plan-service.ts @@ -0,0 +1,2933 @@ +import { z } from 'zod'; +import { getEnv } from '../config/env.js'; +import { db } from '../db/client.js'; +import { shouldTriggerRetryFromActionNote } from './plan-retry-policy.js'; + +export const PLAN_TABLE = 'plan_items'; +export const PLAN_ISSUE_TABLE = 'plan_issue_histories'; +export const PLAN_ACTION_TABLE = 'plan_action_histories'; +export const PLAN_SOURCE_WORK_TABLE = 'plan_source_work_histories'; +export const PLAN_RELEASE_REVIEW_TABLE = 'plan_release_reviews'; +export const planStatuses = ['๋“ฑ๋ก', '์ž‘์—…์ค‘', '์ž‘์—…์™„๋ฃŒ', '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', '์™„๋ฃŒ'] as const; +export const planAutomationTypes = ['none', 'plan', 'command_execution', 'non_source_work', 'auto_worker'] as const; +export const planWorkerStatuses = [ + '๋Œ€๊ธฐ', + '๋ธŒ๋žœ์น˜์ƒ์„ฑ์ค‘', + '๋ธŒ๋žœ์น˜์ค€๋น„', + '์ž๋™์ž‘์—…์ค‘', + 'release๋ฐ˜์˜๋Œ€๊ธฐ', + 'release๋ฐ˜์˜์ค‘', + 'release๋ฐ˜์˜์™„๋ฃŒ', + 'main๋ฐ˜์˜๋Œ€๊ธฐ', + 'main๋ฐ˜์˜์ค‘', + 'main๋ฐ˜์˜์™„๋ฃŒ', + '์ž๋™์™„๋ฃŒ', + '๋ธŒ๋žœ์น˜์‹คํŒจ', + 'release๋ฐ˜์˜์‹คํŒจ', + 'main๋ฐ˜์˜์‹คํŒจ', + '์ž๋™์ž‘์—…์‹คํŒจ', + '์ž‘์—…์ทจ์†Œ', +] as const; +export const planReleaseReviewStatuses = ['pending', 'reviewing', 'approved', 'changes-requested'] as const; + +export type PlanAutomationUsageSnapshot = { + tokenTotals: { + total: number; + input: number; + output: number; + cached: number; + reasoning: number; + }; + totalTokens: number; + retryCount: number; + sourceWorkCount: number; + processingStartedAt: string | null; + processingEndedAt: string | null; + processingEndedAtSource: string | null; + processingDurationSeconds: number | null; +}; + +type PlanRowOptions = { + issueTags?: string[]; + hasOpenIssues?: boolean; + maskNote?: boolean; + noteMasked?: boolean; + releaseReviewNote?: string; +}; + +export const statusSchema = z.enum(planStatuses); + +export const setupSchema = z.object({ + recreate: z.boolean().optional(), +}); + +function resolvePlanAutomationTypeAlias(value: unknown) { + if (typeof value !== 'string') { + return value; + } + + const normalizedValue = value.trim(); + + if (normalizedValue === 'plan_registration') { + return 'plan'; + } + + if (normalizedValue === 'general_development') { + return 'auto_worker'; + } + + return normalizedValue; +} + +export const planAutomationTypeSchema = z.preprocess( + resolvePlanAutomationTypeAlias, + z.enum(planAutomationTypes), +); + +export const createPlanSchema = z.object({ + workId: z.string().trim().optional().default('์ž‘์—…ID'), + note: z.string().default(''), + automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).default('none')), + releaseTarget: z.string().trim().min(1).default('release'), + jangsingProcessingRequired: z.boolean().default(true), + autoDeployToMain: z.boolean().default(true), + repeatRequestEnabled: z.boolean().default(false), + repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).default(60), +}); + +export const updatePlanSchema = z.object({ + workId: z.string().trim().optional(), + note: z.string().optional(), + automationType: z.preprocess(resolvePlanAutomationTypeAlias, z.enum(planAutomationTypes).optional()), + releaseTarget: z.string().trim().min(1).optional(), + jangsingProcessingRequired: z.boolean().optional(), + autoDeployToMain: z.boolean().optional(), + repeatRequestEnabled: z.boolean().optional(), + repeatIntervalMinutes: z.coerce.number().int().min(1).max(1440).optional(), +}); + +export const updatePlanJangsingProcessingSchema = z.object({ + jangsingProcessingRequired: z.boolean(), +}); + +export const issueActionSchema = z.object({ + actionNote: z.string().trim().min(1), + resolve: z.boolean().default(false), + retry: z.boolean().default(false), +}); + +const planListStatusFilterAliases = new Set(['all', 'in-progress', 'done', 'error']); + +export const listPlanQuerySchema = z.object({ + status: z.preprocess((value) => { + if (typeof value !== 'string') { + return value; + } + + const normalizedValue = value.trim(); + + if (!normalizedValue || planListStatusFilterAliases.has(normalizedValue)) { + return undefined; + } + + return normalizedValue; + }, statusSchema.optional()), +}); + +export type PlanStatus = (typeof planStatuses)[number]; +export type PlanAutomationType = (typeof planAutomationTypes)[number]; +export type PlanWorkerStatus = (typeof planWorkerStatuses)[number]; +export type PlanReleaseReviewStatus = (typeof planReleaseReviewStatuses)[number]; + +export const planReleaseReviewStatusSchema = z.enum(planReleaseReviewStatuses); + +const planReleaseReviewMetadataSchema = z.object({ + summary: z.string().trim().min(1).max(500).optional(), + pageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), + checkedPageSelectionIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), + docIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), + componentIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), + widgetIds: z.array(z.string().trim().min(1).max(160)).max(12).optional(), +}); + +export const updatePlanReleaseReviewSchema = z.object({ + status: planReleaseReviewStatusSchema.optional(), + reviewNote: z.string().max(4000).optional(), + metadata: planReleaseReviewMetadataSchema.optional(), +}); + +export type PlanReleaseReviewMetadata = z.infer; + +const planFailureWorkerStatuses = new Set([ + '๋ธŒ๋žœ์น˜์‹คํŒจ', + '์ž๋™์ž‘์—…์‹คํŒจ', + 'release๋ฐ˜์˜์‹คํŒจ', + 'main๋ฐ˜์˜์‹คํŒจ', +]); +const functionCheckEditableStatuses = new Set(['์ž‘์—…์™„๋ฃŒ', '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', '์™„๋ฃŒ']); + +const automationIssueTags = ['#๋ธŒ๋žœ์น˜์‹คํŒจ', '#์ž๋™์ž‘์—…์‹คํŒจ', '#release๋ฐ˜์˜์‹คํŒจ', '#main๋ฐ˜์˜์‹คํŒจ'] as const; +const WORKLOG_EVIDENCE_PATH_PATTERN = /^docs\/(?:worklogs\/.+\.md|assets\/worklogs\/.+)/i; + +function sanitizeBranchToken(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +function normalizePlanWorkId(value?: string | null) { + const workId = String(value ?? '').trim(); + const normalized = workId.replace(/^\[|\]$/g, '').replace(/\s+/g, '').toLowerCase(); + + if (!workId || normalized === '์ž‘์—…id' || normalized === 'workid' || normalized === 'undefined' || normalized === 'null') { + return '์ž‘์—…ID'; + } + + return workId; +} + +export function normalizePlanAutomationType(value: unknown): PlanAutomationType { + const normalizedValue = resolvePlanAutomationTypeAlias(value); + return planAutomationTypes.includes(normalizedValue as PlanAutomationType) + ? (normalizedValue as PlanAutomationType) + : 'none'; +} + +function shouldSkipLifecycleSourceWork(row: Record | undefined | null) { + return normalizePlanAutomationType(row?.automation_type) === 'plan'; +} + +export function formatPlanNotificationLabel(workId: string | null | undefined, id: number) { + const normalizedWorkId = normalizePlanWorkId(workId); + return normalizedWorkId === '์ž‘์—…ID' ? `#${id}` : normalizedWorkId; +} + +export function maskPlanNote(value: unknown) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + const words = normalized.split(' ').filter(Boolean); + + if (words.length === 1) { + const [word] = words; + if (word.length <= 1) { + return '*'; + } + + return `${word[0]}${'*'.repeat(word.length - 1)}`; + } + + return words + .map((word, index) => { + if (word.length <= 1) { + return '*'; + } + + if (index === 0) { + return `${word[0]}${'*'.repeat(word.length - 1)}`; + } + + if (index === words.length - 1) { + return `${'*'.repeat(word.length - 1)}${word[word.length - 1]}`; + } + + return word; + }) + .join(' '); +} + +export function normalizeChangedFiles(rawChangedFiles: string[] | null | undefined) { + return [...new Set((rawChangedFiles ?? []).map((file) => String(file ?? '').trim()).filter(Boolean))]; +} + +export function filterRetryWorklogEvidencePaths( + changedFiles: string[], + existingChangedFilesList: string[][], +) { + const normalizedChangedFiles = normalizeChangedFiles(changedFiles); + const existingEvidencePaths = new Set( + existingChangedFilesList + .flatMap((files) => normalizeChangedFiles(files)) + .filter((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file)), + ); + + return normalizedChangedFiles.filter( + (file) => !WORKLOG_EVIDENCE_PATH_PATTERN.test(file) || !existingEvidencePaths.has(file), + ); +} + +export function buildPlanBranchName(workId: string, id: number) { + const token = sanitizeBranchToken(workId) || `plan-${id}`; + const prefix = /^auto-worklog-\d{4}-\d{2}-\d{2}$/i.test(String(workId ?? '').trim()) ? 'hotfix' : 'feature'; + return `${prefix}/plan-${id}-${token}`; +} + +export function mapPlanRow( + row: Record, + options?: PlanRowOptions, +) { + return { + id: row.id, + workId: row.work_id, + note: options?.maskNote ? maskPlanNote(row.note) : row.note, + automationType: normalizePlanAutomationType(row.automation_type), + releaseReviewNote: options?.releaseReviewNote ?? '', + noteMasked: Boolean(options?.noteMasked), + status: row.status, + jangsingProcessingRequired: + typeof row.jangsing_processing_required === 'boolean' + ? row.jangsing_processing_required + : row.normal_processing_level === '์ƒ', + autoDeployToMain: Boolean(row.auto_deploy_to_main ?? true), + repeatRequestEnabled: Boolean(row.repeat_request_enabled ?? false), + repeatIntervalMinutes: Number(row.repeat_interval_minutes ?? 60), + assignedBranch: row.assigned_branch, + releaseTarget: row.release_target, + workerStatus: row.worker_status, + lastError: row.last_error, + issueTags: options?.issueTags ?? [], + hasOpenIssues: options?.hasOpenIssues ?? false, + startedAt: row.started_at, + completedAt: row.completed_at, + mergedAt: row.merged_at, + usageSnapshot: mapPlanAutomationUsageSnapshot(row.usage_snapshot), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function mapPlanIssueRow(row: Record) { + return { + id: row.id, + planItemId: row.plan_item_id, + issueTag: row.issue_tag, + message: row.message, + actionNote: row.action_note, + resolved: row.resolved, + resolvedAt: row.resolved_at, + createdAt: row.created_at, + }; +} + +export function mapPlanActionRow(row: Record) { + return { + id: row.id, + planItemId: row.plan_item_id, + actionType: row.action_type, + note: row.note, + createdAt: row.created_at, + }; +} + +export function mapPlanSourceWorkRow(row: Record) { + const changedFilesText = String(row.changed_files ?? '[]'); + let changedFiles: string[] = []; + const sourceFilesText = String(row.source_files ?? '[]'); + type SourceFileRow = { + path: string; + previousPath: string | null; + status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown'; + language: string; + content: string; + }; + let sourceFiles: SourceFileRow[] = []; + + try { + changedFiles = normalizeChangedFiles(JSON.parse(changedFilesText) as string[]); + } catch { + changedFiles = []; + } + + try { + sourceFiles = JSON.parse(sourceFilesText) as SourceFileRow[]; + } catch { + sourceFiles = []; + } + + return { + id: row.id, + planItemId: row.plan_item_id, + summary: row.summary, + branchName: row.branch_name, + commitHash: row.commit_hash, + previewUrl: row.preview_url ?? null, + changedFiles, + commandLog: row.command_log, + diffText: row.diff_text, + sourceFiles, + createdAt: row.created_at, + }; +} + +function normalizePlanAutomationUsageSnapshotMetric(value: unknown) { + const normalized = Number(value ?? 0); + return Number.isFinite(normalized) ? Math.max(0, Math.round(normalized)) : 0; +} + +export function mapPlanAutomationUsageSnapshot(value: unknown): PlanAutomationUsageSnapshot | null { + let parsedValue = value; + + if (typeof value === 'string') { + const trimmedValue = value.trim(); + + if (!trimmedValue) { + return null; + } + + try { + parsedValue = JSON.parse(trimmedValue); + } catch { + return null; + } + } + + if (!parsedValue || typeof parsedValue !== 'object') { + return null; + } + + const snapshot = parsedValue as Partial & { + tokenTotals?: Partial; + }; + const tokenTotals: Partial = snapshot.tokenTotals ?? {}; + + return { + tokenTotals: { + total: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.total), + input: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.input), + output: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.output), + cached: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.cached), + reasoning: normalizePlanAutomationUsageSnapshotMetric(tokenTotals.reasoning), + }, + totalTokens: normalizePlanAutomationUsageSnapshotMetric(snapshot.totalTokens), + retryCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.retryCount), + sourceWorkCount: normalizePlanAutomationUsageSnapshotMetric(snapshot.sourceWorkCount), + processingStartedAt: + typeof snapshot.processingStartedAt === 'string' && snapshot.processingStartedAt.trim() + ? snapshot.processingStartedAt + : null, + processingEndedAt: + typeof snapshot.processingEndedAt === 'string' && snapshot.processingEndedAt.trim() + ? snapshot.processingEndedAt + : null, + processingEndedAtSource: + typeof snapshot.processingEndedAtSource === 'string' && snapshot.processingEndedAtSource.trim() + ? snapshot.processingEndedAtSource + : null, + processingDurationSeconds: Number.isFinite(Number(snapshot.processingDurationSeconds)) + ? Math.max(0, Math.round(Number(snapshot.processingDurationSeconds))) + : null, + }; +} + +export function mapPlanReleaseReviewMetadata(value: unknown): PlanReleaseReviewMetadata { + if (typeof value === 'string' && value.trim()) { + try { + return planReleaseReviewMetadataSchema.parse(JSON.parse(value)); + } catch { + return {}; + } + } + + if (value && typeof value === 'object') { + try { + return planReleaseReviewMetadataSchema.parse(value); + } catch { + return {}; + } + } + + return {}; +} + +export function mapPlanReleaseReviewRow(row: Record | null | undefined, planItemId: number) { + const metadata = mapPlanReleaseReviewMetadata(row?.metadata ?? null); + + return { + id: row?.id ? Number(row.id) : null, + planItemId, + status: (row?.status ? String(row.status) : 'pending') as PlanReleaseReviewStatus, + reviewNote: row?.review_note ? String(row.review_note) : '', + checkedByClientId: row?.checked_by_client_id ? String(row.checked_by_client_id) : null, + checkedByNickname: row?.checked_by_nickname ? String(row.checked_by_nickname) : null, + checkedAt: row?.checked_at ? String(row.checked_at) : null, + metadata, + createdAt: row?.created_at ? String(row.created_at) : null, + updatedAt: row?.updated_at ? String(row.updated_at) : null, + }; +} + +function extractAutomationTokenUsageText(sourceWork: Pick, 'summary' | 'commandLog'>) { + const candidates = [ + typeof sourceWork.commandLog === 'string' ? sourceWork.commandLog : null, + typeof sourceWork.summary === 'string' ? sourceWork.summary : null, + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + const line = candidate + .split('\n') + .map((entry) => entry.trim()) + .find((entry) => /^ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰:\s*/.test(entry)); + + if (line) { + return line.replace(/^ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰:\s*/u, '').trim(); + } + } + + return null; +} + +function parseTokenMetricValue(valueText: string, unitText: string | undefined) { + const normalizedValue = Number(String(valueText ?? '').replace(/,/g, '')); + + if (!Number.isFinite(normalizedValue)) { + return null; + } + + const unit = String(unitText ?? '').trim().toLowerCase(); + + if (unit === 'k') { + return Math.round(normalizedValue * 1_000); + } + + if (unit === 'm') { + return Math.round(normalizedValue * 1_000_000); + } + + return Math.round(normalizedValue); +} + +function parseAutomationTokenUsageMetrics(tokenUsageText: string) { + const normalizedText = tokenUsageText + .replace(/^tokens?\s+used\s*:?\s*/iu, '') + .replace(/\(([^)]+)\)/g, ', $1') + .trim(); + const metrics = new Map(); + + for (const match of normalizedText.matchAll(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)\s*(input|output|total|cached|reasoning)\b/giu)) { + const label = match[3]?.toLowerCase() ?? ''; + const value = parseTokenMetricValue(match[1] ?? '', match[2]); + + if (!label || value === null) { + continue; + } + + metrics.set(label, value); + } + + for (const match of normalizedText.matchAll(/\b(input|output|total|cached|reasoning)\s*[:=]?\s*(\d[\d,]*(?:\.\d+)?)\s*([km]?)/giu)) { + const label = match[1]?.toLowerCase() ?? ''; + const value = parseTokenMetricValue(match[2] ?? '', match[3]); + + if (!label || value === null) { + continue; + } + + metrics.set(label, value); + } + + if (metrics.size > 0) { + return metrics; + } + + const fallbackMatch = normalizedText.match(/(\d[\d,]*(?:\.\d+)?)\s*([km]?)/i); + + if (!fallbackMatch) { + return null; + } + + const fallbackValue = parseTokenMetricValue(fallbackMatch[1], fallbackMatch[2]); + + if (fallbackValue === null) { + return null; + } + + return new Map([['total', fallbackValue]]); +} + +function getAutomationTotalTokenCount(metrics: Map) { + const total = metrics.get('total'); + + if (typeof total === 'number' && Number.isFinite(total)) { + return total; + } + + return ['input', 'output', 'cached', 'reasoning'].reduce((sum, key) => sum + (metrics.get(key) ?? 0), 0); +} + +function isPlanAutomationWorkerActive(workerStatus: unknown) { + return [ + '๋ธŒ๋žœ์น˜์ƒ์„ฑ์ค‘', + '๋ธŒ๋žœ์น˜์ค€๋น„', + '์ž๋™์ž‘์—…์ค‘', + 'release๋ฐ˜์˜๋Œ€๊ธฐ', + 'release๋ฐ˜์˜์ค‘', + 'main๋ฐ˜์˜๋Œ€๊ธฐ', + 'main๋ฐ˜์˜์ค‘', + ].includes(String(workerStatus ?? '').trim()); +} + +async function getLatestPlanReviewCheckedAt(planItemId: number) { + const row = await db(PLAN_RELEASE_REVIEW_TABLE) + .select('checked_at', 'updated_at', 'id') + .where({ plan_item_id: planItemId }) + .orderBy([ + { column: 'updated_at', order: 'desc' }, + { column: 'id', order: 'desc' }, + ]) + .first(); + + return row?.checked_at ? String(row.checked_at) : null; +} + +function resolvePlanProcessingEndedAt( + row: Record, + reviewCheckedAt: string | null, +): { endedAt: string | null; source: string | null } { + if (reviewCheckedAt) { + return { + endedAt: reviewCheckedAt, + source: 'review_checked_at', + }; + } + + if (row.merged_at) { + return { + endedAt: String(row.merged_at), + source: 'merged_at', + }; + } + + if (row.completed_at) { + return { + endedAt: String(row.completed_at), + source: 'completed_at', + }; + } + + if (row.updated_at && row.started_at && !isPlanAutomationWorkerActive(row.worker_status)) { + return { + endedAt: String(row.updated_at), + source: 'updated_at', + }; + } + + if (row.started_at && isPlanAutomationWorkerActive(row.worker_status)) { + return { + endedAt: new Date().toISOString(), + source: 'in_progress', + }; + } + + return { + endedAt: null, + source: null, + }; +} + +function buildPlanAutomationUsageSnapshot( + sourceWorks: Array, 'summary' | 'commandLog' | 'createdAt'>>, + row: Record, + reviewCheckedAt: string | null, +): PlanAutomationUsageSnapshot | null { + const totals = new Map(); + + sourceWorks.forEach((sourceWork) => { + const tokenUsageText = extractAutomationTokenUsageText(sourceWork); + + if (!tokenUsageText) { + return; + } + + const metrics = parseAutomationTokenUsageMetrics(tokenUsageText); + + if (!metrics) { + return; + } + + metrics.forEach((value, key) => { + totals.set(key, (totals.get(key) ?? 0) + value); + }); + }); + + const sourceWorkCount = sourceWorks.length; + const processingStartedAt = + row.started_at ? String(row.started_at) : sourceWorks[0]?.createdAt ? String(sourceWorks[0].createdAt) : null; + const { endedAt, source } = resolvePlanProcessingEndedAt(row, reviewCheckedAt); + const processingDurationSeconds = + processingStartedAt && endedAt + ? Math.max( + 0, + Math.round((new Date(endedAt).getTime() - new Date(processingStartedAt).getTime()) / 1_000), + ) + : null; + const totalTokens = getAutomationTotalTokenCount(totals); + + if (sourceWorkCount === 0 && !processingStartedAt && totalTokens === 0) { + return null; + } + + return { + tokenTotals: { + total: Math.max(0, totals.get('total') ?? 0), + input: Math.max(0, totals.get('input') ?? 0), + output: Math.max(0, totals.get('output') ?? 0), + cached: Math.max(0, totals.get('cached') ?? 0), + reasoning: Math.max(0, totals.get('reasoning') ?? 0), + }, + totalTokens, + retryCount: Math.max(0, sourceWorkCount - 1), + sourceWorkCount, + processingStartedAt, + processingEndedAt: endedAt, + processingEndedAtSource: source, + processingDurationSeconds, + }; +} + +export async function syncPlanAutomationUsageSnapshot(planItemId: number) { + await ensurePlanTable(); + + const row = await db(PLAN_TABLE).where({ id: planItemId }).first(); + + if (!row) { + return null; + } + + const sourceWorkRows = await db(PLAN_SOURCE_WORK_TABLE) + .select('summary', 'command_log', 'created_at') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc'); + const reviewCheckedAt = await getLatestPlanReviewCheckedAt(planItemId); + const snapshot = buildPlanAutomationUsageSnapshot( + sourceWorkRows.map((sourceWorkRow) => ({ + summary: sourceWorkRow.summary, + commandLog: sourceWorkRow.command_log, + createdAt: String(sourceWorkRow.created_at), + })), + row, + reviewCheckedAt, + ); + + await db(PLAN_TABLE) + .where({ id: planItemId }) + .update({ + usage_snapshot: snapshot ? JSON.stringify(snapshot) : null, + }); + + return snapshot; +} + +async function listPlanReleaseReviewNoteMap(planItemIds: number[]) { + if (planItemIds.length === 0) { + return new Map(); + } + + const rows = await db(PLAN_RELEASE_REVIEW_TABLE) + .select('plan_item_id', 'review_note', 'updated_at', 'id') + .whereIn('plan_item_id', planItemIds) + .orderBy([ + { column: 'updated_at', order: 'desc' }, + { column: 'id', order: 'desc' }, + ]); + + const noteMap = new Map(); + + for (const row of rows) { + const planItemId = Number(row.plan_item_id); + + if (!Number.isFinite(planItemId) || noteMap.has(planItemId)) { + continue; + } + + noteMap.set(planItemId, row.review_note ? String(row.review_note) : ''); + } + + return noteMap; +} + +async function ensurePlanFailureIssueHistory(planItemId: number) { + const planRow = await db(PLAN_TABLE).where({ id: planItemId }).first(); + + if (!planRow || !planRow.worker_status || !planFailureWorkerStatuses.has(planRow.worker_status)) { + return; + } + + if (!planRow.last_error) { + return; + } + + const existingIssue = await db(PLAN_ISSUE_TABLE) + .where({ + plan_item_id: planItemId, + issue_tag: `#${planRow.worker_status}`, + message: planRow.last_error, + }) + .first(); + + if (existingIssue) { + return; + } + + await db(PLAN_ISSUE_TABLE).insert({ + plan_item_id: planItemId, + issue_tag: `#${planRow.worker_status}`, + message: planRow.last_error, + resolved: false, + }); +} + +async function ensureColumn( + columnName: string, + addColumn: (table: any) => void, +) { + const hasColumn = await db.schema.hasColumn(PLAN_TABLE, columnName); + + if (hasColumn) { + return; + } + + await db.schema.alterTable(PLAN_TABLE, (table) => { + addColumn(table); + }); +} + +async function syncPlanColumns() { + const hasIssueNoteColumn = await db.schema.hasColumn(PLAN_TABLE, 'issue_note'); + + if (hasIssueNoteColumn) { + await db.schema.alterTable(PLAN_TABLE, (table) => { + table.dropColumn('issue_note'); + }); + } + + await ensureColumn('assigned_branch', (table) => { + table.string('assigned_branch', 200).nullable(); + }); + await ensureColumn('release_target', (table) => { + table.string('release_target', 120).notNullable().defaultTo('release'); + }); + await ensureColumn('automation_type', (table) => { + table.string('automation_type', 40).notNullable().defaultTo('none'); + }); + await ensureColumn('auto_deploy_to_main', (table) => { + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + }); + await ensureColumn('repeat_request_enabled', (table) => { + table.boolean('repeat_request_enabled').notNullable().defaultTo(false); + }); + await ensureColumn('repeat_interval_minutes', (table) => { + table.integer('repeat_interval_minutes').notNullable().defaultTo(60); + }); + await ensureColumn('normal_processing_level', (table) => { + table.string('normal_processing_level', 20).notNullable().defaultTo('์ค‘'); + }); + await ensureColumn('jangsing_processing_required', (table) => { + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + }); + await ensureColumn('worker_status', (table) => { + table.string('worker_status', 80).nullable(); + }); + await ensureColumn('last_error', (table) => { + table.text('last_error').nullable(); + }); + await ensureColumn('locked_by', (table) => { + table.string('locked_by', 120).nullable(); + }); + await ensureColumn('locked_at', (table) => { + table.timestamp('locked_at', { useTz: true }).nullable(); + }); + await ensureColumn('started_at', (table) => { + table.timestamp('started_at', { useTz: true }).nullable(); + }); + await ensureColumn('completed_at', (table) => { + table.timestamp('completed_at', { useTz: true }).nullable(); + }); + await ensureColumn('merged_at', (table) => { + table.timestamp('merged_at', { useTz: true }).nullable(); + }); + await ensureColumn('usage_snapshot', (table) => { + table.text('usage_snapshot').nullable(); + }); + + await db(PLAN_TABLE) + .where({ automation_type: 'plan_registration' }) + .update({ automation_type: 'plan' }); + await db(PLAN_TABLE) + .where({ automation_type: 'general_development' }) + .update({ automation_type: 'auto_worker' }); +} + +async function dropPlanWorkIdUniqueConstraint() { + await db.raw( + ` + do $$ + declare constraint_name text; + begin + for constraint_name in + select con.conname + from pg_constraint con + join pg_class rel on rel.oid = con.conrelid + join pg_namespace nsp on nsp.oid = rel.relnamespace + join pg_attribute att on att.attrelid = rel.oid and att.attnum = any(con.conkey) + where nsp.nspname = current_schema() + and rel.relname = '${PLAN_TABLE}' + and con.contype = 'u' + and att.attname = 'work_id' + loop + execute format('alter table %I.%I drop constraint %I', current_schema(), '${PLAN_TABLE}', constraint_name); + end loop; + end + $$; + `, + ); +} + +async function ensurePlanIssueTable() { + const exists = await db.schema.hasTable(PLAN_ISSUE_TABLE); + + if (exists) { + return; + } + + await db.schema.createTable(PLAN_ISSUE_TABLE, (table) => { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.string('issue_tag', 120).notNullable(); + table.text('message').notNullable(); + table.text('action_note').nullable(); + table.boolean('resolved').notNullable().defaultTo(false); + table.timestamp('resolved_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); +} + +async function ensurePlanActionTable() { + const exists = await db.schema.hasTable(PLAN_ACTION_TABLE); + + if (exists) { + return; + } + + await db.schema.createTable(PLAN_ACTION_TABLE, (table) => { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.string('action_type', 120).notNullable(); + table.text('note').notNullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); +} + +async function ensurePlanSourceWorkTable() { + const exists = await db.schema.hasTable(PLAN_SOURCE_WORK_TABLE); + + if (exists) { + const hasPreviewUrlColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'preview_url'); + + if (!hasPreviewUrlColumn) { + await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => { + table.string('preview_url', 2000).nullable(); + }); + } + + const hasSourceFilesColumn = await db.schema.hasColumn(PLAN_SOURCE_WORK_TABLE, 'source_files'); + + if (!hasSourceFilesColumn) { + await db.schema.alterTable(PLAN_SOURCE_WORK_TABLE, (table) => { + table.text('source_files').notNullable().defaultTo('[]'); + }); + } + return; + } + + await db.schema.createTable(PLAN_SOURCE_WORK_TABLE, (table) => { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().index(); + table.text('summary').notNullable(); + table.string('branch_name', 200).notNullable(); + table.string('commit_hash', 80).nullable(); + table.string('preview_url', 2000).nullable(); + table.text('changed_files').notNullable().defaultTo('[]'); + table.text('command_log').nullable(); + table.text('diff_text').nullable(); + table.text('source_files').notNullable().defaultTo('[]'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); +} + +async function ensurePlanReleaseReviewTable() { + const exists = await db.schema.hasTable(PLAN_RELEASE_REVIEW_TABLE); + + if (!exists) { + await db.schema.createTable(PLAN_RELEASE_REVIEW_TABLE, (table) => { + table.increments('id').primary(); + table.integer('plan_item_id').notNullable().unique().index(); + table.string('status', 40).notNullable().defaultTo('pending'); + table.text('review_note').notNullable().defaultTo(''); + table.string('checked_by_client_id', 120).nullable(); + table.string('checked_by_nickname', 80).nullable(); + table.timestamp('checked_at', { useTz: true }).nullable(); + table.text('metadata').notNullable().defaultTo('{}'); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['plan_item_id', (table) => table.integer('plan_item_id').notNullable().unique().index()], + ['status', (table) => table.string('status', 40).notNullable().defaultTo('pending')], + ['review_note', (table) => table.text('review_note').notNullable().defaultTo('')], + ['checked_by_client_id', (table) => table.string('checked_by_client_id', 120).nullable()], + ['checked_by_nickname', (table) => table.string('checked_by_nickname', 80).nullable()], + ['checked_at', (table) => table.timestamp('checked_at', { useTz: true }).nullable()], + ['metadata', (table) => table.text('metadata').notNullable().defaultTo('{}')], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(PLAN_RELEASE_REVIEW_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(PLAN_RELEASE_REVIEW_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function ensurePlanTable() { + const exists = await db.schema.hasTable(PLAN_TABLE); + + if (!exists) { + try { + await db.schema.createTable(PLAN_TABLE, (table) => { + table.increments('id').primary(); + table.string('work_id', 120).notNullable().unique(); + table.text('note').notNullable().defaultTo(''); + table.string('status', 40).notNullable().defaultTo('๋“ฑ๋ก'); + table.boolean('jangsing_processing_required').notNullable().defaultTo(true); + table.boolean('auto_deploy_to_main').notNullable().defaultTo(true); + table.string('assigned_branch', 200).nullable(); + table.string('release_target', 120).notNullable().defaultTo('release'); + table.string('worker_status', 80).nullable(); + table.text('last_error').nullable(); + table.string('locked_by', 120).nullable(); + table.timestamp('locked_at', { useTz: true }).nullable(); + table.timestamp('started_at', { useTz: true }).nullable(); + table.timestamp('completed_at', { useTz: true }).nullable(); + table.timestamp('merged_at', { useTz: true }).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + } catch (error) { + const dbError = error as { code?: string }; + + if (dbError.code !== '42P07' && dbError.code !== '23505') { + throw error; + } + } + } + + await syncPlanColumns(); + await dropPlanWorkIdUniqueConstraint(); + await ensurePlanIssueTable(); + await ensurePlanActionTable(); + await ensurePlanSourceWorkTable(); + await ensurePlanReleaseReviewTable(); + await db(PLAN_TABLE) + .whereNull('jangsing_processing_required') + .update({ + jangsing_processing_required: true, + }); + await db(PLAN_TABLE) + .whereIn('status', ['์ž‘์—…์™„๋ฃŒ', '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', '์™„๋ฃŒ'] as never[]) + .whereNull('completed_at') + .update({ + completed_at: db.raw('coalesce(merged_at, updated_at, created_at)'), + updated_at: db.fn.now(), + }); + await db(PLAN_TABLE) + .where({ status: '์ด์Šˆ' as never }) + .update({ + status: '๋“ฑ๋ก', + worker_status: db.raw("coalesce(worker_status, '๋ธŒ๋žœ์น˜์‹คํŒจ')"), + updated_at: db.fn.now(), + }); + await db(PLAN_TABLE) + .where({ status: '๊ฐœ๋ฐœ์™„๋ฃŒ' as never }) + .update({ + status: '์ž‘์—…์™„๋ฃŒ', + worker_status: db.raw("case when worker_status in ('๋ณ‘ํ•ฉ๋Œ€๊ธฐ','๋ณ‘ํ•ฉ์ค‘') then 'release๋ฐ˜์˜๋Œ€๊ธฐ' when worker_status='๋ณ‘ํ•ฉ์™„๋ฃŒ' then 'main๋ฐ˜์˜์™„๋ฃŒ' else worker_status end"), + updated_at: db.fn.now(), + }); + await db(PLAN_TABLE) + .whereIn('worker_status', ['release๋ฐ˜์˜์™„๋ฃŒ', 'main๋ฐ˜์˜๋Œ€๊ธฐ', 'main๋ฐ˜์˜์ค‘', 'main๋ฐ˜์˜์‹คํŒจ'] as never[]) + .whereNot({ status: '์™„๋ฃŒ' as never }) + .update({ + status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', + updated_at: db.fn.now(), + }); +} + +export async function createPlanItem(payload: z.infer) { + await ensurePlanTable(); + const workId = normalizePlanWorkId(payload.workId); + + const rows = await db(PLAN_TABLE) + .insert({ + work_id: workId, + note: payload.note, + status: '๋“ฑ๋ก', + automation_type: normalizePlanAutomationType(payload.automationType), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + repeat_request_enabled: payload.repeatRequestEnabled, + repeat_interval_minutes: payload.repeatIntervalMinutes, + worker_status: '๋Œ€๊ธฐ', + last_error: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; +} + +export async function createCompletedPlanExecutionLogItem(payload: z.infer) { + await ensurePlanTable(); + const workId = normalizePlanWorkId(payload.workId); + + const rows = await db(PLAN_TABLE) + .insert({ + work_id: workId, + note: payload.note, + status: '์™„๋ฃŒ', + automation_type: normalizePlanAutomationType(payload.automationType), + release_target: payload.releaseTarget, + jangsing_processing_required: payload.jangsingProcessingRequired, + auto_deploy_to_main: payload.autoDeployToMain, + repeat_request_enabled: payload.repeatRequestEnabled, + repeat_interval_minutes: payload.repeatIntervalMinutes, + worker_status: '์ž๋™์™„๋ฃŒ', + last_error: null, + started_at: db.fn.now(), + completed_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + if (rows[0]?.id) { + await syncPlanAutomationUsageSnapshot(Number(rows[0].id)); + } + + return rows[0]; +} + +export async function upsertAutoPlanItem(args: { + workId: string; + note: string; + releaseTarget: string; + jangsingProcessingRequired: boolean; + autoDeployToMain: boolean; + automationType?: PlanAutomationType; + requeue: boolean; +}) { + await ensurePlanTable(); + + const workId = normalizePlanWorkId(args.workId); + const existingRow = await db(PLAN_TABLE) + .where({ work_id: workId }) + .orderBy('id', 'desc') + .first(); + + if (!existingRow) { + return { + row: await createPlanItem({ + workId, + note: args.note, + automationType: normalizePlanAutomationType(args.automationType), + releaseTarget: args.releaseTarget, + jangsingProcessingRequired: args.jangsingProcessingRequired, + autoDeployToMain: args.autoDeployToMain, + repeatRequestEnabled: false, + repeatIntervalMinutes: 60, + }), + action: 'created' as const, + }; + } + + const nextReleaseTarget = args.releaseTarget || existingRow.release_target || 'release'; + const nextAutomationType = normalizePlanAutomationType(args.automationType ?? existingRow.automation_type); + const nextJangsingProcessingRequired = args.jangsingProcessingRequired; + const nextAutoDeployToMain = args.autoDeployToMain; + const nextNote = args.note; + const currentJangsingProcessingRequired = + typeof existingRow.jangsing_processing_required === 'boolean' + ? existingRow.jangsing_processing_required + : existingRow.normal_processing_level === '์ƒ'; + const hasPayloadChange = + existingRow.note !== nextNote || + normalizePlanAutomationType(existingRow.automation_type) !== nextAutomationType || + (existingRow.release_target ?? 'release') !== nextReleaseTarget || + Boolean(existingRow.auto_deploy_to_main ?? true) !== nextAutoDeployToMain || + Boolean(currentJangsingProcessingRequired) !== nextJangsingProcessingRequired; + + if (!args.requeue) { + if (!hasPayloadChange || existingRow.status !== '๋“ฑ๋ก') { + return { + row: existingRow, + action: 'unchanged' as const, + }; + } + + const rows = await db(PLAN_TABLE) + .where({ id: existingRow.id }) + .update({ + note: nextNote, + automation_type: nextAutomationType, + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + updated_at: db.fn.now(), + }) + .returning('*'); + + return { + row: rows[0], + action: 'updated' as const, + }; + } + + const rows = await db(PLAN_TABLE) + .where({ id: existingRow.id }) + .update({ + work_id: workId, + note: nextNote, + status: '๋“ฑ๋ก', + assigned_branch: null, + automation_type: nextAutomationType, + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + worker_status: '๋Œ€๊ธฐ', + last_error: null, + locked_by: null, + locked_at: null, + started_at: null, + completed_at: null, + merged_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanActionHistory( + Number(existingRow.id), + '์ž๋™๊ฐฑ์‹ ', + '์—…๋ฌด์ผ์ง€ ์ž๋™ํ™” ์„ค์ • ๊ธฐ์ค€์œผ๋กœ Plan ํ•ญ๋ชฉ์„ ์ตœ์‹  ์ƒํƒœ๋กœ ๊ฐฑ์‹ ํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + await syncPlanAutomationUsageSnapshot(Number(existingRow.id)); + + return { + row: rows[0], + action: 'requeued' as const, + }; +} + +export async function updatePlanItem(id: number, payload: z.infer) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const nextWorkId = + payload.workId === undefined ? currentRow.work_id : normalizePlanWorkId(payload.workId); + const nextAutomationType = payload.automationType ?? normalizePlanAutomationType(currentRow.automation_type); + const nextReleaseTarget = payload.releaseTarget ?? currentRow.release_target ?? 'release'; + const nextJangsingProcessingRequired = + payload.jangsingProcessingRequired ?? + (typeof currentRow.jangsing_processing_required === 'boolean' + ? currentRow.jangsing_processing_required + : currentRow.normal_processing_level === '์ƒ'); + const nextAutoDeployToMain = payload.autoDeployToMain ?? currentRow.auto_deploy_to_main ?? true; + const nextRepeatRequestEnabled = payload.repeatRequestEnabled ?? currentRow.repeat_request_enabled ?? false; + const nextRepeatIntervalMinutes = payload.repeatIntervalMinutes ?? currentRow.repeat_interval_minutes ?? 60; + const nextNote = payload.note ?? currentRow.note; + + const isOnlyJangsingUpdate = + nextWorkId === currentRow.work_id && + nextAutomationType === normalizePlanAutomationType(currentRow.automation_type) && + nextReleaseTarget === (currentRow.release_target ?? 'release') && + nextAutoDeployToMain === (currentRow.auto_deploy_to_main ?? true) && + nextRepeatRequestEnabled === (currentRow.repeat_request_enabled ?? false) && + nextRepeatIntervalMinutes === (currentRow.repeat_interval_minutes ?? 60) && + nextNote === currentRow.note; + + if (payload.jangsingProcessingRequired !== undefined && isOnlyJangsingUpdate) { + return updatePlanItemJangsingProcessingRequired(id, nextJangsingProcessingRequired); + } + + if (currentRow.started_at || currentRow.status !== '๋“ฑ๋ก') { + throw new Error('์ž‘์—…์‹œ์ž‘ ์ดํ›„์—๋Š” ์›๋ณธ ์š”์ฒญ์„ ์ˆ˜์ •ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ถ”๊ฐ€ ์กฐ์น˜์‚ฌํ•ญ์€ ์ด๋ ฅ์— ๊ธฐ๋กํ•ด ์ฃผ์„ธ์š”.'); + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + work_id: nextWorkId, + note: nextNote, + status: currentRow.status, + automation_type: nextAutomationType, + release_target: nextReleaseTarget, + jangsing_processing_required: nextJangsingProcessingRequired, + auto_deploy_to_main: nextAutoDeployToMain, + repeat_request_enabled: nextRepeatRequestEnabled, + repeat_interval_minutes: nextRepeatIntervalMinutes, + worker_status: currentRow.worker_status, + last_error: currentRow.last_error, + completed_at: currentRow.completed_at, + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; +} + +export async function updatePlanItemJangsingProcessingRequired( + id: number, + jangsingProcessingRequired: boolean, +) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + if (!functionCheckEditableStatuses.has(currentRow.status)) { + throw new Error('๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ์€ ์ž‘์—…์™„๋ฃŒ ๊ฑด๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + jangsing_processing_required: jangsingProcessingRequired, + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0] ?? null; +} + +export async function deletePlanItem(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + await db.transaction(async (trx) => { + await trx(PLAN_SOURCE_WORK_TABLE).where({ plan_item_id: id }).delete(); + await trx(PLAN_ACTION_TABLE).where({ plan_item_id: id }).delete(); + await trx(PLAN_ISSUE_TABLE).where({ plan_item_id: id }).delete(); + await trx(PLAN_TABLE).where({ id }).delete(); + }); + + return currentRow; +} + +export async function getBoardPostLinkedToPlanItem(planItemId: number) { + const boardPostsTable = 'board_posts'; + const hasBoardPostsTable = await db.schema.hasTable(boardPostsTable); + + if (!hasBoardPostsTable) { + return null; + } + + return db(boardPostsTable).select('id', 'title').where({ automation_plan_item_id: planItemId }).first(); +} + +export async function markPlanAsDevelopmentComplete(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '์ž‘์—…์™„๋ฃŒ', + jangsing_processing_required: true, + worker_status: 'release๋ฐ˜์˜๋Œ€๊ธฐ', + last_error: null, + locked_by: null, + locked_at: null, + completed_at: currentRow.completed_at ?? db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanLifecycleSourceWorkHistory( + id, + '์ž‘์—…์™„๋ฃŒ ์ฒ˜๋ฆฌ๋กœ release ๋ฐ˜์˜ ๋Œ€๊ธฐ ์ƒํƒœ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.', + currentRow.assigned_branch ?? currentRow.release_target ?? 'release', + currentRow.assigned_branch + ? `ํ˜„์žฌ ์ž‘์—… ๋ธŒ๋žœ์น˜: ${currentRow.assigned_branch}` + : `release ๋Œ€๊ธฐ ๋ธŒ๋žœ์น˜: ${currentRow.release_target ?? 'release'}`, + ); + await createPlanActionHistory(id, '์ž‘์—…์™„๋ฃŒ', '์ž‘์—…์™„๋ฃŒ ์ฒ˜๋ฆฌ'); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function markPlanAsCompleted( + id: number, + note?: string, + workerStatus: Extract = '์ž๋™์™„๋ฃŒ', +) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '์™„๋ฃŒ', + worker_status: workerStatus, + last_error: null, + locked_by: null, + locked_at: null, + completed_at: currentRow.completed_at ?? db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + const sourceWorkCountRow = await db(PLAN_SOURCE_WORK_TABLE) + .where({ plan_item_id: id }) + .count<{ count: string }>('id as count') + .first(); + const sourceWorkCount = Number(sourceWorkCountRow?.count ?? 0); + + if (sourceWorkCount === 0 && !shouldSkipLifecycleSourceWork(currentRow)) { + await createPlanLifecycleSourceWorkHistory( + id, + note ?? '์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ๊ฒ€ํ† ํ•˜๊ณ  ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + currentRow.assigned_branch ?? currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH, + currentRow.assigned_branch + ? `์™„๋ฃŒ ๊ธฐ์ค€ ๋ธŒ๋žœ์น˜: ${currentRow.assigned_branch}` + : `์™„๋ฃŒ ๊ธฐ์ค€ ๋ธŒ๋žœ์น˜: ${currentRow.release_target ?? getEnv().PLAN_MAIN_BRANCH}`, + ); + } + + await createPlanActionHistory(id, '์™„๋ฃŒ์ฒ˜๋ฆฌ', note ?? '์ž‘์—…์„ ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.'); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function markPlanAsStarted(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '์ž‘์—…์ค‘', + worker_status: currentRow.worker_status === '๋Œ€๊ธฐ' ? null : currentRow.worker_status, + last_error: currentRow.last_error, + started_at: currentRow.started_at ?? db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanActionHistory(id, '์ž‘์—…์‹œ์ž‘', '์ž‘์—…์„ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.'); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function retryPlanBranch(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '๋“ฑ๋ก', + worker_status: '๋Œ€๊ธฐ', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanActionHistory(id, '๋ธŒ๋žœ์น˜์žฌ์‹œ๋„', '๋ธŒ๋žœ์น˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.'); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function retryPlanMerge(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const isMainRetry = + currentRow.worker_status === 'main๋ฐ˜์˜์‹คํŒจ' || + currentRow.worker_status === 'main๋ฐ˜์˜๋Œ€๊ธฐ' || + currentRow.worker_status === 'main๋ฐ˜์˜์ค‘' || + currentRow.status === '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ'; + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: isMainRetry ? '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ' : '์ž‘์—…์™„๋ฃŒ', + worker_status: isMainRetry ? 'main๋ฐ˜์˜๋Œ€๊ธฐ' : 'release๋ฐ˜์˜๋Œ€๊ธฐ', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanActionHistory( + id, + isMainRetry ? 'main๋ฐ˜์˜์žฌ์‹œ๋„' : 'release๋ฐ˜์˜์žฌ์‹œ๋„', + isMainRetry ? 'main ์ผ๊ด„ ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.' : 'release ๋ฐ˜์˜ ์žฌ์‹œ๋„๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function requestPlanMainMerge(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + if (currentRow.status !== '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ') { + return { + item: await getPlanItemById(id), + message: 'main ๋ฐ˜์˜ ์š”์ฒญ์€ release ๋ฐ˜์˜์ด ์™„๋ฃŒ๋œ ์ดํ›„์—๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.', + }; + } + + const releaseTarget = String(currentRow.release_target ?? 'release'); + const pendingRows = await db(PLAN_TABLE) + .select('id') + .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', release_target: releaseTarget }); + + const targetIds = pendingRows.map((row) => Number(row.id)).filter(Number.isFinite); + + await db(PLAN_TABLE) + .whereIn('id', targetIds.length > 0 ? targetIds : [id]) + .update({ + last_error: null, + locked_by: null, + locked_at: null, + worker_status: 'main๋ฐ˜์˜๋Œ€๊ธฐ', + updated_at: db.fn.now(), + }); + + await createPlanActionHistory( + id, + 'main๋ฐ˜์˜์š”์ฒญ', + `${releaseTarget} ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€ main ์ผ๊ด„ ๋ฐ˜์˜์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ); + await Promise.all((targetIds.length > 0 ? targetIds : [id]).map((targetId) => syncPlanAutomationUsageSnapshot(targetId))); + + return { + item: await getPlanItemById(id), + message: `${releaseTarget} ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€์œผ๋กœ ${Math.max(targetIds.length, 1)}๊ฑด main ์ผ๊ด„ ๋ฐ˜์˜์„ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.`, + }; +} + +export async function retryPlanWork(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '์ž‘์—…์ค‘', + worker_status: '๋ธŒ๋žœ์น˜์ค€๋น„', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + await createPlanActionHistory(id, '์ž‘์—…์žฌ์ฒ˜๋ฆฌ', '์ž๋™ ์ž‘์—… ์žฌ์ฒ˜๋ฆฌ๋ฅผ ์š”์ฒญํ–ˆ์Šต๋‹ˆ๋‹ค.'); + + return rows[0]; +} + +export async function isPlanLockedByWorker(id: number, workerId: string) { + await ensurePlanTable(); + + const row = await db(PLAN_TABLE) + .select('id') + .where({ id, locked_by: workerId }) + .first(); + + return Boolean(row); +} + +export async function resumePlanDevelopmentFromRelease(id: number, actionNote: string) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + if (currentRow.status !== '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ') { + return { + didScheduleRetry: false, + item: await getPlanItemById(id), + message: null, + }; + } + + const rows = await db(PLAN_TABLE) + .where({ id }) + .update({ + status: '์ž‘์—…์ค‘', + worker_status: '๋ธŒ๋žœ์น˜์ค€๋น„', + last_error: null, + locked_by: null, + locked_at: null, + merged_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + await syncPlanAutomationUsageSnapshot(id); + + await createPlanActionHistory( + id, + '๋ฆด๋ฆฌ์ฆˆ์ถ”๊ฐ€์กฐ์น˜', + `release ์ƒํƒœ ์ถ”๊ฐ€ ์กฐ์น˜๋กœ ๊ฐœ๋ฐœ์„ ์žฌ๊ฐœํ–ˆ์Šต๋‹ˆ๋‹ค.\n${actionNote}`, + ); + + return { + didScheduleRetry: false, + item: await getPlanItemById(id), + message: 'release ์ƒํƒœ์˜ ์ถ”๊ฐ€ ์กฐ์น˜๋ฅผ ๋ฐ˜์˜ํ•ด ์ถ”๊ฐ€ ๊ฐœ๋ฐœ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.', + row: rows[0], + }; +} + +export async function queuePlanRetryFromFailure(id: number, actionNote?: string) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + return { + didScheduleRetry: false, + item: await getPlanItemById(id), + message: shouldTriggerRetryFromActionNote(actionNote ?? '') + ? '์กฐ์น˜ ์ด๋ ฅ์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž๋™ ์žฌ์‹œ์ž‘์€ ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ํ•„์š”ํ•˜๋ฉด ์žฌ์‹œ๋„ ์•ก์…˜์„ ์ง์ ‘ ์‹คํ–‰ํ•ด ์ฃผ์„ธ์š”.' + : '์กฐ์น˜ ์ด๋ ฅ์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž๋™ํ™”๋Š” ์ค‘๋‹จ๋œ ์ƒํƒœ๋กœ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.', + }; +} + +export function shouldResumePlanDevelopmentFromIssueAction( + status: PlanStatus | string | null | undefined, + retry: boolean, +) { + return retry && status === '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ'; +} + +async function queuePlanRetryForCurrentStatus(id: number, workerStatus: string | null | undefined) { + if (workerStatus === '์ž๋™์ž‘์—…์ค‘') { + await retryPlanWork(id); + return { + didScheduleRetry: true, + item: await getPlanItemById(id), + message: '์‹คํ–‰ ์ค‘์ธ ์ž๋™ ์ž‘์—…์„ ์ •๋ฆฌํ•˜๊ณ  ์ด์Šˆ ์กฐ์น˜๋ฅผ ๋ฐ˜์˜ํ•˜๋„๋ก ๋‹ค์‹œ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.', + }; + } + + if (workerStatus === '๋ธŒ๋žœ์น˜์‹คํŒจ') { + await retryPlanBranch(id); + return { + didScheduleRetry: true, + item: await getPlanItemById(id), + message: '์ด์Šˆ ์กฐ์น˜๋ฅผ ๋ฐ˜์˜ํ•ด ๋ธŒ๋žœ์น˜ ์žฌ์‹œ๋„๋ฅผ ์˜ˆ์•ฝํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + if (workerStatus === '์ž๋™์ž‘์—…์‹คํŒจ') { + await retryPlanWork(id); + return { + didScheduleRetry: true, + item: await getPlanItemById(id), + message: '์ด์Šˆ ์กฐ์น˜๋ฅผ ๋ฐ˜์˜ํ•ด ์ž๋™ ์ž‘์—… ์žฌ์ฒ˜๋ฆฌ๋ฅผ ์˜ˆ์•ฝํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + if (workerStatus === 'release๋ฐ˜์˜์‹คํŒจ') { + await retryPlanMerge(id); + return { + didScheduleRetry: true, + item: await getPlanItemById(id), + message: '์ด์Šˆ ์กฐ์น˜๋ฅผ ๋ฐ˜์˜ํ•ด release ๋ฐ˜์˜ ์žฌ์ฒ˜๋ฆฌ๋ฅผ ์˜ˆ์•ฝํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + if (workerStatus === 'main๋ฐ˜์˜์‹คํŒจ') { + return { + didScheduleRetry: true, + ...(await requestPlanMainMerge(id)), + }; + } + + return { + didScheduleRetry: false, + item: await getPlanItemById(id), + message: '์ด์Šˆ ์กฐ์น˜ ์ด๋ ฅ์„ ์ €์žฅํ–ˆ๊ณ  ์ž๋™ ์žฌ์ฒ˜๋ฆฌ ๋Œ€์ƒ์€ ์—†์Šต๋‹ˆ๋‹ค.', + }; +} + +export async function queuePlanRetryFromIssueAction(id: number, actionNote: string, retry = false) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + if (!retry) { + return { + didScheduleRetry: false, + item: await getPlanItemById(id), + message: '์ด์Šˆ ์กฐ์น˜ ์ด๋ ฅ์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. ์žฌ์ฒ˜๋ฆฌ๋Š” ์š”์ฒญํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + }; + } + + if (shouldResumePlanDevelopmentFromIssueAction(currentRow.status, retry)) { + const resumeResult = await resumePlanDevelopmentFromRelease(id, actionNote); + + return { + ...resumeResult, + didScheduleRetry: Boolean(resumeResult?.message), + }; + } + + return queuePlanRetryForCurrentStatus(id, currentRow.worker_status); +} + +export async function cancelPlanRelease(id: number) { + await ensurePlanTable(); + + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const releaseTarget = currentRow.release_target ?? 'release'; + const isReleaseMergeFailure = currentRow.status === '์ž‘์—…์™„๋ฃŒ' && currentRow.worker_status === 'release๋ฐ˜์˜์‹คํŒจ'; + const targetRows = isReleaseMergeFailure + ? [] + : await db(PLAN_TABLE) + .select('*') + .where({ release_target: releaseTarget, status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ' }); + + const targetIds = isReleaseMergeFailure + ? [id] + : [...new Set(targetRows.map((row) => Number(row.id)).concat(id))]; + const historyMessage = isReleaseMergeFailure + ? `release(${releaseTarget}) ๋ฐ˜์˜ ์‹คํŒจ ์ƒํƒœ์—์„œ ์ž‘์—…์„ ์ทจ์†Œ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.` + : `release(${releaseTarget}) ๋ฐฐํฌ ๋‚ด์—ญ์„ ๋กค๋ฐฑํ•˜๊ณ  ์ž‘์—…์„ ์ทจ์†Œ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.`; + const resultMessage = isReleaseMergeFailure + ? `release(${releaseTarget}) ๋ฐ˜์˜ ์‹คํŒจ ์ƒํƒœ์—์„œ ์ž‘์—…์ทจ์†Œ๋กœ ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.` + : `release(${releaseTarget}) ๋ฐฐํฌ ๋‚ด์—ญ์„ ๋กค๋ฐฑํ•˜๊ณ  ์ž‘์—…์ทจ์†Œ๋กœ ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.`; + + if (targetIds.length === 0) { + return { + item: await getPlanItemById(id), + message: '์ทจ์†Œํ•  release ๋ฐ˜์˜ ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.', + }; + } + + await db(PLAN_TABLE) + .whereIn('id', targetIds) + .update({ + status: '์™„๋ฃŒ', + worker_status: '์ž‘์—…์ทจ์†Œ', + last_error: null, + locked_by: null, + locked_at: null, + completed_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + for (const targetId of targetIds) { + await createPlanActionHistory( + targetId, + '์ž‘์—…์ทจ์†Œ', + historyMessage, + ); + + const issueRow = await createPlanIssueHistory( + targetId, + '์ž‘์—…์ทจ์†Œ', + historyMessage, + ); + + await db(PLAN_ISSUE_TABLE) + .where({ id: issueRow.id }) + .update({ + resolved: true, + resolved_at: db.fn.now(), + }); + + await syncPlanAutomationUsageSnapshot(targetId); + } + + return { + item: await getPlanItemById(id), + message: resultMessage, + }; +} + +export async function createPlanActionHistory(planItemId: number, actionType: string, note: string) { + await ensurePlanTable(); + + const rows = await db(PLAN_ACTION_TABLE) + .insert({ + plan_item_id: planItemId, + action_type: actionType, + note, + }) + .returning('*'); + + return rows[0]; +} + +export async function resolveAutomationIssueHistories(planItemId: number) { + await ensurePlanTable(); + + await db(PLAN_ISSUE_TABLE) + .where({ plan_item_id: planItemId, resolved: false }) + .whereIn('issue_tag', [...automationIssueTags]) + .update({ + resolved: true, + resolved_at: db.fn.now(), + }); +} + +export async function listPlanActionHistories(planItemId: number) { + await ensurePlanTable(); + + return db(PLAN_ACTION_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); +} + +export async function createPlanSourceWorkHistory( + planItemId: number, + payload: { + summary: string; + branchName: string; + commitHash?: string | null; + previewUrl?: string | null; + changedFiles?: string[]; + commandLog?: string | null; + diffText?: string | null; + sourceFiles?: Array<{ + path: string; + previousPath?: string | null; + status: 'added' | 'modified' | 'deleted' | 'renamed' | 'binary' | 'unknown'; + language: string; + content: string; + }>; + }, +) { + await ensurePlanTable(); + + const rawChangedFiles = payload.changedFiles ?? []; + const changedFiles = normalizeChangedFiles(rawChangedFiles); + + let filteredChangedFiles = changedFiles; + + if (changedFiles.some((file) => WORKLOG_EVIDENCE_PATH_PATTERN.test(file))) { + const rows = await db(PLAN_SOURCE_WORK_TABLE) + .select('changed_files') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc'); + const existingChangedFilesList = rows.map((row) => { + try { + return JSON.parse(String(row.changed_files ?? '[]')) as string[]; + } catch { + return []; + } + }); + + filteredChangedFiles = filterRetryWorklogEvidencePaths(changedFiles, existingChangedFilesList); + } + + const rows = await db(PLAN_SOURCE_WORK_TABLE) + .insert({ + plan_item_id: planItemId, + summary: payload.summary, + branch_name: payload.branchName, + commit_hash: payload.commitHash ?? null, + preview_url: payload.previewUrl ?? null, + changed_files: JSON.stringify(filteredChangedFiles), + command_log: payload.commandLog ?? null, + diff_text: payload.diffText ?? null, + source_files: JSON.stringify(payload.sourceFiles ?? []), + }) + .returning('*'); + + await syncPlanAutomationUsageSnapshot(planItemId); + + return rows[0]; +} + +async function createPlanLifecycleSourceWorkHistory( + planItemId: number, + summary: string, + branchName: string, + commandLog?: string | null, + previewUrl?: string | null, +) { + return createPlanSourceWorkHistory(planItemId, { + summary, + branchName, + previewUrl, + changedFiles: [], + commandLog: commandLog ?? null, + diffText: null, + sourceFiles: [], + }); +} + +export async function listPlanSourceWorkHistories(planItemId: number) { + await ensurePlanTable(); + + return db(PLAN_SOURCE_WORK_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); +} + +export async function getPlanSourceWorkHistory(planItemId: number, sourceWorkId: number) { + await ensurePlanTable(); + + return db(PLAN_SOURCE_WORK_TABLE) + .select('*') + .where({ + id: sourceWorkId, + plan_item_id: planItemId, + }) + .first(); +} + +export async function listLatestPlanSourceWorkMap(planItemIds: number[]) { + if (planItemIds.length === 0) { + return new Map>(); + } + + const rows = await db(PLAN_SOURCE_WORK_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); + + const latestMap = new Map>(); + const fallbackMap = new Map>(); + + rows.forEach((row) => { + const planItemId = Number(row.plan_item_id); + const mappedRow = mapPlanSourceWorkRow(row); + const hasMeaningfulChanges = mappedRow.changedFiles.length > 0 || mappedRow.sourceFiles.length > 0; + + if (!fallbackMap.has(planItemId)) { + fallbackMap.set(planItemId, mappedRow); + } + + if (!latestMap.has(planItemId) && hasMeaningfulChanges) { + latestMap.set(planItemId, mappedRow); + } + }); + + planItemIds.forEach((planItemId) => { + if (!latestMap.has(planItemId) && fallbackMap.has(planItemId)) { + latestMap.set(planItemId, fallbackMap.get(planItemId)!); + } + }); + + return latestMap; +} + +async function listPlanReleaseReviewRowMap(planItemIds: number[]) { + if (planItemIds.length === 0) { + return new Map>(); + } + + const rows = await db(PLAN_RELEASE_REVIEW_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc'); + + const reviewMap = new Map>(); + + rows.forEach((row) => { + const planItemId = Number(row.plan_item_id); + + if (!reviewMap.has(planItemId)) { + reviewMap.set(planItemId, row); + } + }); + + return reviewMap; +} + +function summarizePlanReviewText(value: string) { + return String(value ?? '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 220); +} + +function inferReleaseReviewPageSelectionIds(changedFiles: string[]) { + const selectionIds = new Set(['page:plans:release-review', 'page:plans:release']); + + changedFiles.forEach((file) => { + const normalized = String(file ?? '').trim(); + + if (!normalized) { + return; + } + + if (normalized.startsWith('src/features/board/') || normalized.includes('/BoardPage.tsx')) { + selectionIds.add('page:plans:board'); + } + + if (normalized.startsWith('src/features/history/')) { + selectionIds.add('page:plans:history'); + } + + if (normalized.startsWith('src/components/') || normalized.startsWith('src/features/layout/')) { + selectionIds.add('page:apis:components'); + } + + if (normalized.startsWith('src/widgets/')) { + selectionIds.add('page:apis:widgets'); + } + }); + + return Array.from(selectionIds); +} + +function inferReleaseReviewDocIds(changedFiles: string[]) { + return Array.from( + new Set( + changedFiles + .map((file) => { + const normalized = String(file ?? '').trim(); + return normalized.startsWith('docs/') ? normalized.replace(/[^\w-]+/g, '-') : ''; + }) + .filter(Boolean), + ), + ); +} + +function toKebabCase(value: string) { + return value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/[_\s]+/g, '-') + .toLowerCase(); +} + +function extractSampleTargetIds( + sourceFiles: Array<{ path: string; content: string }> | undefined, + changedFiles: string[], + pathPrefix: string, +) { + const targetIds = new Set(); + + (sourceFiles ?? []).forEach((sourceFile) => { + const normalizedPath = String(sourceFile.path ?? '').trim(); + + if (!normalizedPath.startsWith(pathPrefix)) { + return; + } + + const matches = String(sourceFile.content ?? '').matchAll(/componentId\s*:\s*['"]([^'"]+)['"]/g); + + for (const match of matches) { + const targetId = String(match[1] ?? '').trim(); + + if (targetId) { + targetIds.add(targetId); + } + } + }); + + changedFiles.forEach((file) => { + const normalized = String(file ?? '').trim(); + + if (!normalized.startsWith(pathPrefix)) { + return; + } + + const relativePath = normalized.slice(pathPrefix.length); + const firstSegment = relativePath.split('/').filter(Boolean)[0]; + + if (firstSegment) { + targetIds.add(toKebabCase(firstSegment)); + } + }); + + return Array.from(targetIds); +} + +function inferReleaseReviewComponentIds( + sourceFiles: Array<{ path: string; content: string }> | undefined, + changedFiles: string[], +) { + return extractSampleTargetIds(sourceFiles, changedFiles, 'src/components/'); +} + +function inferReleaseReviewWidgetIds( + sourceFiles: Array<{ path: string; content: string }> | undefined, + changedFiles: string[], +) { + return extractSampleTargetIds(sourceFiles, changedFiles, 'src/widgets/'); +} + +function buildReleaseReviewSummary( + row: Record, + latestSourceWork: ReturnType | undefined, + reviewRow: Record | undefined, +) { + const metadata = mapPlanReleaseReviewMetadata(reviewRow?.metadata ?? null); + const candidateSummary = + metadata.summary ?? + summarizePlanReviewText(String(latestSourceWork?.summary ?? '')) ?? + summarizePlanReviewText(String(row.note ?? '')); + + return candidateSummary || summarizePlanReviewText(String(row.note ?? '')); +} + +export async function upsertPlanReleaseReview( + planItemId: number, + payload: z.infer, + actor?: { + clientId?: string | null; + nickname?: string | null; + }, +) { + await ensurePlanTable(); + + const currentItem = await db(PLAN_TABLE).where({ id: planItemId }).first(); + + if (!currentItem) { + return null; + } + + const currentRow = await db(PLAN_RELEASE_REVIEW_TABLE).where({ plan_item_id: planItemId }).first(); + const currentMetadata = mapPlanReleaseReviewMetadata(currentRow?.metadata ?? null); + const nextStatus = payload.status ?? ((currentRow?.status ? String(currentRow.status) : 'pending') as PlanReleaseReviewStatus); + const nextReviewNote = payload.reviewNote ?? String(currentRow?.review_note ?? ''); + const nextMetadata = payload.metadata ? { ...currentMetadata, ...payload.metadata } : currentMetadata; + const hasReviewerTrace = nextStatus !== 'pending' || nextReviewNote.trim().length > 0; + + const nextRow = { + plan_item_id: planItemId, + status: nextStatus, + review_note: nextReviewNote, + checked_by_client_id: hasReviewerTrace ? actor?.clientId?.trim() || null : null, + checked_by_nickname: hasReviewerTrace ? actor?.nickname?.trim() || actor?.clientId?.trim() || null : null, + checked_at: hasReviewerTrace ? db.fn.now() : null, + metadata: JSON.stringify(nextMetadata), + updated_at: db.fn.now(), + }; + + if (currentRow) { + const rows = await db(PLAN_RELEASE_REVIEW_TABLE) + .where({ plan_item_id: planItemId }) + .update(nextRow) + .returning('*'); + + await syncPlanAutomationUsageSnapshot(planItemId); + return mapPlanReleaseReviewRow(rows[0], planItemId); + } + + const rows = await db(PLAN_RELEASE_REVIEW_TABLE) + .insert({ + ...nextRow, + created_at: db.fn.now(), + }) + .returning('*'); + + await syncPlanAutomationUsageSnapshot(planItemId); + return mapPlanReleaseReviewRow(rows[0], planItemId); +} + +export async function listPlanReleaseReviewBoardItems(options?: Pick) { + await ensurePlanTable(); + + const rows = await db(PLAN_TABLE).select('*').orderBy('updated_at', 'desc').orderBy('id', 'desc'); + const releaseRows = rows.filter((row) => { + const workerStatus = String(row.worker_status ?? ''); + return ( + row.status === '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ' || + row.status === '์™„๋ฃŒ' || + ['release๋ฐ˜์˜์ค‘', 'main๋ฐ˜์˜๋Œ€๊ธฐ', 'main๋ฐ˜์˜์ค‘', 'main๋ฐ˜์˜์‹คํŒจ', 'release๋ฐ˜์˜์™„๋ฃŒ'].includes(workerStatus) + ); + }); + + const planItemIds = releaseRows.map((row) => Number(row.id)); + const issueSummaryMap = await listPlanIssueSummaries(planItemIds); + const latestSourceWorkMap = await listLatestPlanSourceWorkMap(planItemIds); + const reviewRowMap = await listPlanReleaseReviewRowMap(planItemIds); + + return releaseRows.map((row) => { + const planItemId = Number(row.id); + const planItem = mapPlanRow(row, { + ...(issueSummaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }), + maskNote: options?.maskNote, + noteMasked: options?.maskNote, + }); + const latestSourceWork = latestSourceWorkMap.get(planItemId) ?? null; + const reviewRow = reviewRowMap.get(planItemId); + const review = mapPlanReleaseReviewRow(reviewRow, planItemId); + const changedFiles = latestSourceWork?.changedFiles ?? []; + const sourceFiles = latestSourceWork?.sourceFiles ?? []; + const metadata = { + ...review.metadata, + summary: review.metadata.summary ?? buildReleaseReviewSummary(row, latestSourceWork ?? undefined, reviewRow), + pageSelectionIds: review.metadata.pageSelectionIds ?? inferReleaseReviewPageSelectionIds(changedFiles), + docIds: review.metadata.docIds ?? inferReleaseReviewDocIds(changedFiles), + componentIds: review.metadata.componentIds ?? inferReleaseReviewComponentIds(sourceFiles, changedFiles), + widgetIds: review.metadata.widgetIds ?? inferReleaseReviewWidgetIds(sourceFiles, changedFiles), + }; + + return { + planItem, + review: { + ...review, + metadata, + }, + latestSourceWork, + }; + }); +} + +export async function createPlanIssueHistory( + planItemId: number, + issueTag: string, + message: string, +) { + await ensurePlanTable(); + + const normalizedIssueTag = issueTag.startsWith('#') ? issueTag : `#${issueTag}`; + const previousIssueWithAction = await db(PLAN_ISSUE_TABLE) + .select('*') + .where({ + plan_item_id: planItemId, + issue_tag: normalizedIssueTag, + message, + }) + .whereNotNull('action_note') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .first(); + + const rows = await db(PLAN_ISSUE_TABLE) + .insert({ + plan_item_id: planItemId, + issue_tag: normalizedIssueTag, + message, + action_note: previousIssueWithAction?.action_note ?? null, + }) + .returning('*'); + + return rows[0]; +} + +export async function appendLatestIssueAction(planItemId: number, actionNote: string, resolve = false) { + await ensurePlanTable(); + + const issueRow = await db(PLAN_ISSUE_TABLE) + .where({ plan_item_id: planItemId }) + .orderBy('resolved', 'asc') + .orderBy('created_at', 'desc') + .first(); + + if (!issueRow) { + throw new Error('๊ธฐ๋กํ•  ์ด์Šˆ ์ด๋ ฅ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + } + + const nextActionNote = issueRow.action_note + ? `${issueRow.action_note}\n[${new Date().toISOString()}] ${actionNote}` + : `[${new Date().toISOString()}] ${actionNote}`; + + const rows = await db(PLAN_ISSUE_TABLE) + .where({ id: issueRow.id }) + .update({ + action_note: nextActionNote, + resolved: resolve ? true : issueRow.resolved, + resolved_at: resolve ? db.fn.now() : issueRow.resolved_at, + }) + .returning('*'); + + await createPlanActionHistory( + planItemId, + resolve ? '์ด์Šˆํ•ด๊ฒฐ์กฐ์น˜' : '์ด์Šˆ์กฐ์น˜', + actionNote, + ); + + return rows[0]; +} + +export async function listPlanIssueHistories(planItemId: number) { + await ensurePlanTable(); + await ensurePlanFailureIssueHistory(planItemId); + + return db(PLAN_ISSUE_TABLE) + .select('*') + .where({ plan_item_id: planItemId }) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); +} + +export async function listPlanIssueSummaries(planItemIds: number[]) { + await ensurePlanTable(); + + if (planItemIds.length === 0) { + return new Map(); + } + + for (const planItemId of planItemIds) { + await ensurePlanFailureIssueHistory(planItemId); + } + + const rows = await db(PLAN_ISSUE_TABLE) + .select('*') + .whereIn('plan_item_id', planItemIds) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); + + const summaryMap = new Map(); + + rows.forEach((row) => { + const planItemId = Number(row.plan_item_id); + const current = summaryMap.get(planItemId) ?? { issueTags: [], hasOpenIssues: false }; + + if (!row.resolved && typeof row.issue_tag === 'string' && !current.issueTags.includes(row.issue_tag)) { + current.issueTags.push(row.issue_tag); + } + + if (!row.resolved) { + current.hasOpenIssues = true; + } + + summaryMap.set(planItemId, current); + }); + + return summaryMap; +} + +export async function claimNextPlanForBranch(workerId: string) { + await ensurePlanTable(); + const env = getEnv(); + + return db.transaction(async (trx) => { + const row = await trx(PLAN_TABLE) + .select('*') + .where({ status: '๋“ฑ๋ก' }) + .where((builder) => { + builder.whereNull('worker_status').orWhere('worker_status', '๋Œ€๊ธฐ'); + }) + .orderBy('created_at', 'asc') + .forUpdate() + .skipLocked() + .first(); + + if (!row) { + return null; + } + + const assignedBranch = env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_BRANCH : buildPlanBranchName(String(row.work_id), Number(row.id)); + const rows = await trx(PLAN_TABLE) + .where({ id: row.id }) + .update({ + status: '์ž‘์—…์ค‘', + assigned_branch: assignedBranch, + worker_status: '๋ธŒ๋žœ์น˜์ƒ์„ฑ์ค‘', + last_error: null, + locked_by: workerId, + locked_at: db.fn.now(), + started_at: row.started_at ?? db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; + }); +} + +export async function claimNextPlanForExecution(workerId: string) { + await ensurePlanTable(); + + return db.transaction(async (trx) => { + const row = await trx(PLAN_TABLE) + .select('*') + .where({ status: '์ž‘์—…์ค‘', worker_status: '๋ธŒ๋žœ์น˜์ค€๋น„' }) + .whereNotNull('assigned_branch') + .orderBy('updated_at', 'asc') + .forUpdate() + .skipLocked() + .first(); + + if (!row) { + return null; + } + + const rows = await trx(PLAN_TABLE) + .where({ id: row.id }) + .update({ + worker_status: '์ž๋™์ž‘์—…์ค‘', + last_error: null, + locked_by: workerId, + locked_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; + }); +} + +export async function claimNextPlanForMerge(workerId: string) { + await ensurePlanTable(); + + return db.transaction(async (trx) => { + const row = await trx(PLAN_TABLE) + .select('*') + .where({ status: '์ž‘์—…์™„๋ฃŒ' }) + .whereNotNull('assigned_branch') + .where((builder) => { + builder.whereNull('worker_status').orWhere('worker_status', 'release๋ฐ˜์˜๋Œ€๊ธฐ'); + }) + .orderBy('updated_at', 'asc') + .forUpdate() + .skipLocked() + .first(); + + if (!row) { + return null; + } + + const rows = await trx(PLAN_TABLE) + .where({ id: row.id }) + .update({ + worker_status: 'release๋ฐ˜์˜์ค‘', + last_error: null, + locked_by: workerId, + locked_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0]; + }); +} + +export async function claimNextPlanForMainMerge(workerId: string) { + await ensurePlanTable(); + + return db.transaction(async (trx) => { + const candidateRows = await trx(PLAN_TABLE) + .select('*') + .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', worker_status: 'main๋ฐ˜์˜๋Œ€๊ธฐ' }) + .whereNotNull('assigned_branch') + .orderBy('updated_at', 'asc') + .forUpdate(); + + const row = candidateRows[0]; + + if (!row) { + return null; + } + + const pendingReleaseRow = await trx(PLAN_TABLE) + .select('id') + .where({ release_target: row.release_target, status: '์ž‘์—…์™„๋ฃŒ' }) + .first(); + + if (pendingReleaseRow) { + return null; + } + + const batchRows = await trx(PLAN_TABLE) + .select('id') + .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', release_target: row.release_target }) + .whereIn('worker_status', ['main๋ฐ˜์˜๋Œ€๊ธฐ', 'main๋ฐ˜์˜์‹คํŒจ'] as never[]); + + const batchIds = batchRows.map((candidate) => Number(candidate.id)).filter(Number.isFinite); + + const rows = await trx(PLAN_TABLE) + .whereIn('id', batchIds.length > 0 ? batchIds : [Number(row.id)]) + .update({ + worker_status: 'main๋ฐ˜์˜์ค‘', + last_error: null, + locked_by: workerId, + locked_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + return rows[0] ?? null; + }); +} + +export async function markPlanBranchReady(id: number, workerId: string) { + const rows = await db(PLAN_TABLE) + .where({ id }) + .where({ locked_by: workerId }) + .update({ + worker_status: '๋ธŒ๋žœ์น˜์ค€๋น„', + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + if (!rows[0]) { + return null; + } + + return rows[0]; +} + +export async function markPlanWorkCompleted(id: number, workerId: string, note?: string) { + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + const rows = await db(PLAN_TABLE) + .where({ id }) + .where({ locked_by: workerId }) + .update({ + status: '์ž‘์—…์™„๋ฃŒ', + jangsing_processing_required: true, + worker_status: 'release๋ฐ˜์˜๋Œ€๊ธฐ', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + if (!rows[0]) { + return null; + } + + if (currentRow) { + await createPlanLifecycleSourceWorkHistory( + id, + '์ž๋™ ์ž‘์—… ์™„๋ฃŒ๋กœ release ๋ฐ˜์˜ ๋Œ€๊ธฐ ์ƒํƒœ๋กœ ์ „ํ™˜ํ–ˆ์Šต๋‹ˆ๋‹ค.', + currentRow.assigned_branch ?? currentRow.release_target ?? 'release', + currentRow.assigned_branch + ? `ํ˜„์žฌ ์ž‘์—… ๋ธŒ๋žœ์น˜: ${currentRow.assigned_branch}` + : `release ๋Œ€๊ธฐ ๋ธŒ๋žœ์น˜: ${currentRow.release_target ?? 'release'}`, + ); + } + await createPlanActionHistory(id, '์ž‘์—…์™„๋ฃŒ', note ?? '์ž๋™ ์ž‘์—…์„ ๋งˆ์น˜๊ณ  release ๋ฐ˜์˜์„ ๋Œ€๊ธฐํ•ฉ๋‹ˆ๋‹ค.'); + await resolveAutomationIssueHistories(id); + + return rows[0]; +} + +export async function markPlanReleaseMerged(id: number, workerId: string) { + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + const autoDeployToMain = Boolean(currentRow?.auto_deploy_to_main ?? true); + const rows = await db(PLAN_TABLE) + .where({ id }) + .where({ locked_by: workerId }) + .update({ + status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', + worker_status: autoDeployToMain ? 'main๋ฐ˜์˜๋Œ€๊ธฐ' : 'release๋ฐ˜์˜์™„๋ฃŒ', + last_error: null, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + if (!rows[0]) { + return null; + } + + if (currentRow) { + await createPlanLifecycleSourceWorkHistory( + id, + 'release ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.', + currentRow.release_target ?? 'release', + `release ๋ธŒ๋žœ์น˜: ${currentRow.release_target ?? 'release'}`, + ); + } + await createPlanActionHistory( + id, + 'release๋ฐ˜์˜์™„๋ฃŒ', + autoDeployToMain + ? 'release ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์„ ์™„๋ฃŒํ–ˆ๊ณ  main ์ž๋™ ๋ฐ˜์˜์„ ์˜ˆ์•ฝํ–ˆ์Šต๋‹ˆ๋‹ค.' + : 'release ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + await resolveAutomationIssueHistories(id); + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function markPlanMerged(id: number, workerId: string) { + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow || currentRow.locked_by !== workerId) { + return null; + } + + const batchRows = await db(PLAN_TABLE) + .select('*') + .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', release_target: currentRow.release_target }) + .whereIn('worker_status', ['main๋ฐ˜์˜๋Œ€๊ธฐ', 'main๋ฐ˜์˜์ค‘', 'main๋ฐ˜์˜์‹คํŒจ'] as never[]); + + const batchIds = batchRows.map((row) => Number(row.id)).filter(Number.isFinite); + const targetRows = batchRows.length > 0 ? batchRows : [currentRow]; + + const rows = await db(PLAN_TABLE) + .whereIn('id', batchIds.length > 0 ? batchIds : [id]) + .update({ + status: '์™„๋ฃŒ', + worker_status: 'main๋ฐ˜์˜์™„๋ฃŒ', + last_error: null, + locked_by: null, + locked_at: null, + merged_at: db.fn.now(), + completed_at: db.fn.now(), + updated_at: db.fn.now(), + }) + .returning('*'); + + for (const row of targetRows) { + const planId = Number(row.id); + await createPlanLifecycleSourceWorkHistory( + planId, + `${getEnv().PLAN_MAIN_BRANCH} ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.`, + getEnv().PLAN_MAIN_BRANCH, + `${row.release_target ?? 'release'} -> ${getEnv().PLAN_MAIN_BRANCH}`, + ); + await createPlanActionHistory( + planId, + 'main๋ฐ˜์˜์™„๋ฃŒ', + `${row.release_target ?? 'release'} ๋ธŒ๋žœ์น˜๋ฅผ ${getEnv().PLAN_MAIN_BRANCH} ๋ธŒ๋žœ์น˜์— ์ผ๊ด„ ๋ฐ˜์˜ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ); + await resolveAutomationIssueHistories(planId); + await syncPlanAutomationUsageSnapshot(planId); + } + + return { + mergedRow: rows[0] ?? null, + notificationRows: targetRows, + }; +} + +export async function markPlanMainMergeFailure(releaseTarget: string, workerId: string, errorMessage: string) { + await ensurePlanTable(); + + const rows = await db(PLAN_TABLE) + .where({ status: '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ', release_target: releaseTarget, locked_by: workerId }) + .whereIn('worker_status', ['main๋ฐ˜์˜๋Œ€๊ธฐ', 'main๋ฐ˜์˜์ค‘'] as never[]) + .update({ + worker_status: 'main๋ฐ˜์˜์‹คํŒจ', + last_error: errorMessage, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + for (const row of rows) { + await createPlanActionHistory( + Number(row.id), + 'main๋ฐ˜์˜์‹คํŒจ', + `${releaseTarget} ๋ธŒ๋žœ์น˜ ๊ธฐ์ค€ main ์ผ๊ด„ ๋ฐ˜์˜์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ); + await syncPlanAutomationUsageSnapshot(Number(row.id)); + } + + return rows; +} + +export async function markPlanAutomationFailure( + id: number, + workerId: string, + workerStatus: Extract, + errorMessage: string, +) { + const currentRow = await db(PLAN_TABLE).where({ id }).first(); + + if (!currentRow) { + return null; + } + + const nextStatus = + workerStatus === '๋ธŒ๋žœ์น˜์‹คํŒจ' + ? '๋“ฑ๋ก' + : workerStatus === '์ž๋™์ž‘์—…์‹คํŒจ' + ? '์ž‘์—…์ค‘' + : workerStatus === 'main๋ฐ˜์˜์‹คํŒจ' + ? '๋ฆด๋ฆฌ์ฆˆ์™„๋ฃŒ' + : '์ž‘์—…์™„๋ฃŒ'; + const rows = await db(PLAN_TABLE) + .where({ id }) + .where({ locked_by: workerId }) + .update({ + status: nextStatus, + worker_status: workerStatus, + last_error: errorMessage, + locked_by: null, + locked_at: null, + updated_at: db.fn.now(), + }) + .returning('*'); + + if (!rows[0]) { + return null; + } + + const issueHistory = await createPlanIssueHistory(id, workerStatus, errorMessage); + + if (issueHistory?.action_note) { + await createPlanActionHistory( + id, + '์˜ค๋ฅ˜์žฌ๋ฐœ', + `๋™์ผ ์˜ค๋ฅ˜๊ฐ€ ๋‹ค์‹œ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.\n๊ธฐ์กด ์กฐ์น˜๋‚ด์—ญ:\n${issueHistory.action_note}`, + ); + } + + await syncPlanAutomationUsageSnapshot(id); + + return rows[0]; +} + +export async function listPlanItems(status?: PlanStatus, options?: Pick) { + await ensurePlanTable(); + + const buildListQuery = () => { + const builder = db(PLAN_TABLE).select('*').orderBy('id', 'desc'); + + if (status) { + builder.where('status', status); + } + + return builder; + }; + + let rows = await buildListQuery(); + const missingSnapshotIds = rows + .filter((row) => row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') + .map((row) => Number(row.id)) + .filter(Number.isFinite); + + if (missingSnapshotIds.length > 0) { + await Promise.all(missingSnapshotIds.map((planItemId) => syncPlanAutomationUsageSnapshot(planItemId))); + rows = await buildListQuery(); + } + + const planItemIds = rows.map((row) => Number(row.id)); + const issueSummaryMap = await listPlanIssueSummaries(planItemIds); + const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap(planItemIds); + + return rows.map((row) => + mapPlanRow(row, { + ...(issueSummaryMap.get(Number(row.id)) ?? { issueTags: [], hasOpenIssues: false }), + maskNote: options?.maskNote, + noteMasked: options?.maskNote, + releaseReviewNote: releaseReviewNoteMap.get(Number(row.id)) ?? '', + }), + ); +} + +export async function getPlanItemById(id: number, options?: Pick) { + await ensurePlanTable(); + await ensurePlanFailureIssueHistory(id); + let row = await db(PLAN_TABLE).where({ id }).first(); + + if (!row) { + return null; + } + + if (row.usage_snapshot === null || row.usage_snapshot === undefined || String(row.usage_snapshot).trim() === '') { + await syncPlanAutomationUsageSnapshot(id); + row = await db(PLAN_TABLE).where({ id }).first(); + } + + const issueSummaryMap = await listPlanIssueSummaries([id]); + const releaseReviewNoteMap = await listPlanReleaseReviewNoteMap([id]); + return mapPlanRow(row, { + ...(issueSummaryMap.get(id) ?? { issueTags: [], hasOpenIssues: false }), + maskNote: options?.maskNote, + noteMasked: options?.maskNote, + releaseReviewNote: releaseReviewNoteMap.get(id) ?? '', + }); +} + +function normalizePreviewLookupValue(value: string | null | undefined, stripSearch = false) { + const trimmed = String(value ?? '').trim(); + + if (!trimmed) { + return ''; + } + + try { + const url = new URL(trimmed); + url.hash = ''; + + if (stripSearch) { + url.search = ''; + } + + return url.toString().replace(/\/+$/, ''); + } catch { + const withoutHash = trimmed.replace(/#.*$/, ''); + const withoutSearch = stripSearch ? withoutHash.replace(/\?.*$/, '') : withoutHash; + return withoutSearch.replace(/\/+$/, ''); + } +} + +function buildPreviewLookupCandidates(value: string | null | undefined) { + return Array.from( + new Set([ + normalizePreviewLookupValue(value, false), + normalizePreviewLookupValue(value, true), + ].filter(Boolean)), + ); +} + +export async function findLatestPlanItem() { + await ensurePlanTable(); + + const row = await db(PLAN_TABLE).select('id').orderBy('id', 'desc').first(); + + if (!row?.id) { + return null; + } + + return getPlanItemById(Number(row.id)); +} + +export async function findPlanItemByWorkId(workId: string) { + await ensurePlanTable(); + + const normalizedWorkId = normalizePlanWorkId(workId); + + if (normalizedWorkId === '์ž‘์—…ID') { + return null; + } + + const rows = await db(PLAN_TABLE) + .select('id', 'work_id') + .orderBy('id', 'desc'); + + const matchedRow = rows.find((row) => normalizePlanWorkId(String(row.work_id ?? '')) === normalizedWorkId); + + if (!matchedRow?.id) { + return null; + } + + return getPlanItemById(Number(matchedRow.id)); +} + +export async function findPlanItemByPreviewUrl(previewUrl: string) { + await ensurePlanTable(); + + const lookupCandidates = new Set(buildPreviewLookupCandidates(previewUrl)); + + if (lookupCandidates.size === 0) { + return null; + } + + const rows = await db(PLAN_SOURCE_WORK_TABLE) + .select('plan_item_id', 'preview_url') + .whereNotNull('preview_url') + .orderBy('created_at', 'desc') + .orderBy('id', 'desc'); + + const matchedRow = rows.find((row) => + buildPreviewLookupCandidates(String(row.preview_url ?? '')).some((candidate) => lookupCandidates.has(candidate)), + ); + + if (!matchedRow?.plan_item_id) { + return null; + } + + return getPlanItemById(Number(matchedRow.plan_item_id)); +} diff --git a/etc/servers/work-server/src/services/server-command-service.test.ts b/etc/servers/work-server/src/services/server-command-service.test.ts new file mode 100644 index 0000000..d86d4ce --- /dev/null +++ b/etc/servers/work-server/src/services/server-command-service.test.ts @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { EventEmitter } from 'node:events'; +import { writeFile } from 'node:fs/promises'; +import { env } from '../config/env.js'; +import { + buildHealthCheckUrls, + buildRestartFailureMessage, + buildServerCommandApiRestartUrl, + listServerCommands, + resolveDockerSocketPath, + restartServerCommand, +} from './server-command-service.js'; + +test('buildRestartFailureMessage includes exit info and stderr output', () => { + const message = buildRestartFailureMessage( + 'TEST', + Object.assign(new Error('Command failed'), { + code: 1, + stderr: 'no such service: app', + stdout: '', + }), + ); + + assert.match(message, /TEST ์žฌ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค\./); + assert.match(message, /exit:1/); + assert.match(message, /no such service: app/); +}); + +test('listServerCommands uses app as the default test restart service', async () => { + const commands = await listServerCommands(); + const testCommand = commands.find((item) => item.key === 'test'); + + assert.ok(testCommand); + assert.equal(testCommand.serviceName, 'app'); +}); + +test('listServerCommands resolves restart script from main project when project root fallback is needed', async () => { + const commands = await listServerCommands(); + const testCommand = commands.find((item) => item.key === 'test'); + + assert.ok(testCommand); + assert.match(testCommand.commandScript, /\/etc\/commands\/server-command\/restart-test\.sh$/); + assert.notEqual(testCommand.commandScript, '/etc/commands/server-command/restart-test.sh'); +}); + +test('test and release restart scripts fall back to Docker socket when docker CLI is unavailable', () => { + const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); + const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8'); + const relScript = fs.readFileSync(new URL('restart-rel.sh', commandsRoot), 'utf8'); + const workServerScript = fs.readFileSync(new URL('restart-work-server.sh', commandsRoot), 'utf8'); + const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8'); + + assert.match(testScript, /command -v docker >/); + assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/); + assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/); + assert.match(testScript, /restart-via-docker-socket\.mjs/); + assert.match(testScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1\}"/); + assert.match(relScript, /command -v docker >/); + assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/); + assert.match(relScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/); + assert.match(relScript, /restart-via-docker-socket\.mjs/); + assert.match(relScript, /SERVER_COMMAND_CONTAINER_NAME="\$\{SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-release\}"/); + assert.match( + workServerScript, + /docker compose -f etc\/servers\/work-server\/docker-compose\.yml up -d --build --force-recreate --no-deps work-server/, + ); + assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); +}); + +test('work-server package dev script does not use watch mode and rebuilds before start', async () => { + const packageJsonPath = new URL('../../package.json', import.meta.url); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + scripts?: Record; + }; + + assert.equal(packageJson.scripts?.dev, 'npm run build && npm run start'); + assert.doesNotMatch(String(packageJson.scripts?.dev ?? ''), /\bwatch\b/i); +}); + +test('buildServerCommandApiRestartUrl replaces key placeholders on configured command api endpoint', () => { + assert.equal( + buildServerCommandApiRestartUrl('http://127.0.0.1:3200/', '/commands/{key}/restart', 'work-server'), + 'http://127.0.0.1:3200/commands/work-server/restart', + ); +}); + +test('buildServerCommandApiRestartUrl avoids duplicating an existing base path prefix', () => { + assert.equal( + buildServerCommandApiRestartUrl( + 'http://127.0.0.1:3211/api', + '/api/server-commands/{key}/actions/restart', + 'test', + ), + 'http://127.0.0.1:3211/api/server-commands/test/actions/restart', + ); +}); + +test('buildHealthCheckUrls adds localhost fallbacks for command-runner', () => { + assert.deepEqual(buildHealthCheckUrls('command-runner', 'http://host.docker.internal:3211/health'), [ + 'http://host.docker.internal:3211/health', + 'http://127.0.0.1:3211/health', + 'http://localhost:3211/health', + ]); + assert.deepEqual(buildHealthCheckUrls('work-server', 'http://host.docker.internal:3100/health'), [ + 'http://host.docker.internal:3100/health', + ]); +}); + +test('resolveDockerSocketPath prefers explicit socket path and falls back to unix DOCKER_HOST', () => { + assert.equal( + resolveDockerSocketPath({ + SERVER_COMMAND_DOCKER_SOCKET: '/custom/docker.sock', + DOCKER_HOST: 'unix:///run/user/1000/docker.sock', + }), + '/custom/docker.sock', + ); + + assert.equal( + resolveDockerSocketPath({ + DOCKER_HOST: 'unix:///run/user/1000/docker.sock', + }), + '/run/user/1000/docker.sock', + ); +}); + +test('restartServerCommand delegates to configured command api when base url is provided', async (t) => { + const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL; + const originalAccessToken = env.SERVER_COMMAND_API_ACCESS_TOKEN; + const originalPathTemplate = env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE; + + env.SERVER_COMMAND_API_BASE_URL = 'http://127.0.0.1:3200/api'; + env.SERVER_COMMAND_API_ACCESS_TOKEN = 'local-command-token'; + env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = '/commands/{key}/restart'; + + const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request, init?: RequestInit) => { + assert.equal(String(input), 'http://127.0.0.1:3200/api/commands/test/restart'); + assert.equal(init?.method, 'POST'); + const headers = new Headers(init?.headers); + assert.equal(headers.get('X-Access-Token'), 'local-command-token'); + + return new Response( + JSON.stringify({ + restartState: 'accepted', + item: { + key: 'test', + label: 'TEST', + composeStatus: 'restarting', + }, + commandOutput: 'restart accepted', + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }, + ); + }); + + try { + const result = await restartServerCommand('test'); + + assert.equal(fetchMock.mock.callCount(), 1); + assert.equal(result.restartState, 'accepted'); + assert.equal(result.commandOutput, 'restart accepted'); + assert.equal(result.server.key, 'test'); + assert.equal(result.server.composeStatus, 'restarting'); + } finally { + env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl; + env.SERVER_COMMAND_API_ACCESS_TOKEN = originalAccessToken; + env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE = originalPathTemplate; + } +}); + +test('restartServerCommand surfaces deferred restart script failures for work-server', async (t) => { + const originalBaseUrl = env.SERVER_COMMAND_API_BASE_URL; + env.SERVER_COMMAND_API_BASE_URL = ''; + const childProcessModule = (await import('node:child_process')) as { spawn: (...args: unknown[]) => unknown }; + + const spawnMock = t.mock.method( + childProcessModule, + 'spawn', + (...spawnArgs: unknown[]) => { + const args = Array.isArray(spawnArgs[1]) ? (spawnArgs[1] as string[]) : []; + const shellCommand = String(args[1] ?? ''); + const logPath = shellCommand.match(/>"([^"]+\.log)"/)?.[1] ?? ''; + const statusPath = shellCommand.match(/>"([^"]+\.status)"/)?.[1] ?? ''; + + queueMicrotask(() => { + void writeFile(statusPath, '1', 'utf8'); + void writeFile(logPath, 'docker compose failed', 'utf8'); + }); + + const child = new EventEmitter() as EventEmitter & { unref(): void }; + child.unref = () => undefined; + queueMicrotask(() => { + child.emit('spawn'); + }); + return child; + }, + ); + + try { + await assert.rejects(() => restartServerCommand('work-server'), /WORK-SERVER ์žฌ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค\./); + assert.equal(spawnMock.mock.callCount(), 1); + } finally { + env.SERVER_COMMAND_API_BASE_URL = originalBaseUrl; + } +}); + +test('listServerCommands marks command-runner online when localhost fallback responds', async (t) => { + const fetchMock = t.mock.method(globalThis, 'fetch', async (input: string | URL | Request) => { + const url = String(input); + + if (url === 'http://host.docker.internal:3211/health') { + throw new Error('fetch failed'); + } + + if (url === 'http://127.0.0.1:3211/health') { + return new Response(JSON.stringify({ ok: true, service: 'server-command-runner' }), { + status: 200, + headers: { + 'content-type': 'application/json', + }, + }); + } + + return new Response('ok', { status: 200 }); + }); + + const commands = await listServerCommands(); + const runnerCommand = commands.find((item) => item.key === 'command-runner'); + + assert.ok(runnerCommand); + assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://host.docker.internal:3211/health'), true); + assert.equal(fetchMock.mock.calls.some((call) => String(call.arguments[0]) === 'http://127.0.0.1:3211/health'), true); + assert.equal(runnerCommand.availability, 'online'); + assert.equal(runnerCommand.httpStatus, 200); + assert.match(String(runnerCommand.errorMessage ?? ''), /fallback health check succeeded via http:\/\/127\.0\.0\.1:3211\/health/); +}); diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts new file mode 100755 index 0000000..d33db28 --- /dev/null +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -0,0 +1,1121 @@ +import { execFile, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import { readFile, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { env } from '../config/env.js'; +import { + getRuntimeWorkServerBuildInfo, + readLatestWorkServerBuildInfo, + readLatestWorkServerSourceChange, +} from './work-server-build-service.js'; + +const execFileAsync = promisify(execFile); + +export const serverCommandKeys = ['test', 'rel', 'work-server', 'command-runner'] as const; + +export type ServerCommandKey = (typeof serverCommandKeys)[number]; + +type ServerDefinition = { + key: ServerCommandKey; + label: string; + summary: string; + environment: string; + publicUrl: string | null; + checkUrl: string; + composeFile: string; + serviceName: string; + containerName: string; + commandScript: string; + commandWorkingDirectory: string; + commandEnvironment: Record; + restartStrategy: 'wait' | 'deferred'; +}; + +export type ServerCommandSnapshot = { + key: ServerCommandKey; + label: string; + summary: string; + environment: string; + publicUrl: string | null; + checkUrl: string; + composeFile: string; + serviceName: string; + availability: 'online' | 'degraded' | 'offline'; + httpStatus: number | null; + contentType: string | null; + responsePreview: string | null; + checkedAt: string; + startedAt: string | null; + runningVersion: string | null; + runningBuiltAt: string | null; + latestVersion: string | null; + latestBuiltAt: string | null; + latestSourceChangeAt: string | null; + latestSourceChangePath: string | null; + buildRequired: boolean; + updateAvailable: boolean; + updateSummary: string | null; + responseTimeMs: number | null; + composeStatus: string | null; + composeDetails: string | null; + lastCommand: string; + commandScript: string; + commandWorkingDirectory: string; + errorMessage: string | null; +}; + +export type ServerCommandRestartResult = { + server: ServerCommandSnapshot; + commandOutput: string | null; + restartState: 'completed' | 'accepted'; +}; + +type ExecFileFailure = Error & { + code?: number | string; + signal?: NodeJS.Signals | string | null; + stdout?: string; + stderr?: string; +}; + +type RemoteRestartPayload = { + server?: ServerCommandSnapshot; + commandOutput: string | null; + restartState: 'completed' | 'accepted'; +}; + +type HealthCheckAttempt = { + url: string; + httpStatus: number | null; + contentType: string | null; + responsePreview: string | null; + availability: ServerCommandSnapshot['availability']; + errorMessage: string | null; +}; + +type RuntimeInspectionResult = { + startedAt: string | null; + composeStatus: string | null; + composeDetails: string | null; + availability?: ServerCommandSnapshot['availability']; + responsePreview?: string | null; + errorMessage?: string | null; +}; + +type BuildInspectionResult = { + runningVersion: string | null; + runningBuiltAt: string | null; + latestVersion: string | null; + latestBuiltAt: string | null; + latestSourceChangeAt: string | null; + latestSourceChangePath: string | null; + buildRequired: boolean; + updateAvailable: boolean; + updateSummary: string | null; +}; + +const RUNNER_HEARTBEAT_FRESHNESS_MS = 30_000; +const DEFERRED_RESTART_DELAY_MS = 2_000; +const DEFERRED_RESTART_CONFIRM_TIMEOUT_MS = 4_500; +const DEFERRED_RESTART_POLL_INTERVAL_MS = 150; + +function normalizeUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +function normalizeOptionalUrl(value: string | null | undefined) { + const normalized = value?.trim(); + return normalized ? normalizeUrl(normalized) : null; +} + +function normalizePath(value: string) { + return path.resolve(value); +} + +export function buildServerCommandApiRestartUrl(baseUrl: string, pathTemplate: string, key: ServerCommandKey) { + const normalizedBaseUrl = normalizeUrl(baseUrl); + const normalizedPath = pathTemplate.trim() || '/api/server-commands/{key}/actions/restart'; + const pathName = normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`; + const resolvedPath = pathName.replaceAll('{key}', encodeURIComponent(key)); + const baseUrlObject = new URL(`${normalizedBaseUrl}/`); + const basePath = baseUrlObject.pathname.replace(/\/+$/, ''); + const nextPath = + basePath && resolvedPath === basePath + ? resolvedPath + : basePath && resolvedPath.startsWith(`${basePath}/`) + ? resolvedPath + : `${basePath}${resolvedPath}`.replace(/\/{2,}/g, '/'); + + baseUrlObject.pathname = nextPath.startsWith('/') ? nextPath : `/${nextPath}`; + baseUrlObject.search = ''; + baseUrlObject.hash = ''; + + return normalizeUrl(baseUrlObject.toString()); +} + +function resolveCommandScriptPath(scriptName: string, preferredRoots: string[]) { + const resolvedRoots = preferredRoots + .map((root) => normalizePath(root)) + .filter((root, index, array) => Boolean(root) && array.indexOf(root) === index); + const candidates = resolvedRoots.map((root) => path.join(root, 'etc', 'commands', 'server-command', scriptName)); + const existingPath = candidates.find((candidate) => fs.existsSync(candidate)); + + return existingPath ?? candidates[0]; +} + +export function resolveDockerSocketPath(source: NodeJS.ProcessEnv | Record = process.env) { + const explicitSocketPath = source.SERVER_COMMAND_DOCKER_SOCKET?.trim(); + if (explicitSocketPath) { + return explicitSocketPath; + } + + const dockerHost = source.DOCKER_HOST?.trim(); + if (dockerHost?.startsWith('unix://')) { + return dockerHost.slice('unix://'.length); + } + + return '/var/run/docker.sock'; +} + +function shouldRetryWithDockerSocket(error: unknown) { + const failure = error instanceof Error ? (error as ExecFileFailure) : null; + const detail = [failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'); + + return failure?.code === 127 || /docker CLI not found/i.test(detail); +} + +export function buildHealthCheckUrls(key: ServerCommandKey, checkUrl: string) { + const normalized = normalizeUrl(checkUrl); + + if (key !== 'command-runner') { + return [normalized]; + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(normalized); + } catch { + return [normalized]; + } + + const hostVariants = + parsedUrl.hostname === 'host.docker.internal' + ? ['host.docker.internal', '127.0.0.1', 'localhost'] + : parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === 'localhost' + ? [parsedUrl.hostname, parsedUrl.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1', 'host.docker.internal'] + : [parsedUrl.hostname]; + + const dedupedUrls: string[] = []; + + for (const hostname of hostVariants) { + const candidate = new URL(parsedUrl.toString()); + candidate.hostname = hostname; + const serialized = normalizeUrl(candidate.toString()); + + if (!dedupedUrls.includes(serialized)) { + dedupedUrls.push(serialized); + } + } + + return dedupedUrls; +} + +async function fetchHealthCheck(url: string): Promise { + try { + const response = await fetch(url, { + method: 'GET', + redirect: 'follow', + signal: AbortSignal.timeout(5000), + }); + const bodyText = await response.text(); + + return { + url, + httpStatus: response.status, + contentType: response.headers.get('content-type'), + responsePreview: trimPreview(bodyText), + availability: response.ok ? 'online' : response.status < 500 ? 'degraded' : 'offline', + errorMessage: null, + }; + } catch (error) { + return { + url, + httpStatus: null, + contentType: null, + responsePreview: null, + availability: 'offline', + errorMessage: error instanceof Error ? error.message : '์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } +} + +async function restartViaDockerSocket(definition: ServerDefinition) { + const mergedEnv = { + ...process.env, + ...definition.commandEnvironment, + }; + const socketPath = resolveDockerSocketPath(mergedEnv); + const socketRestartScript = path.join(path.dirname(definition.commandScript), 'restart-via-docker-socket.mjs'); + + return execFileAsync('node', [socketRestartScript, definition.containerName], { + cwd: definition.commandWorkingDirectory, + timeout: 30000, + maxBuffer: 1024 * 1024, + env: { + ...mergedEnv, + SERVER_COMMAND_DOCKER_SOCKET: socketPath, + }, + }); +} + +function getServerDefinitions(): ServerDefinition[] { + const projectRoot = normalizePath(env.SERVER_COMMAND_PROJECT_ROOT); + const mainProjectRoot = normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT); + + return [ + { + key: 'test', + label: 'TEST', + summary: '๋ฉ”์ธ ํ”„๋กœ์ ํŠธ์˜ ํ…Œ์ŠคํŠธ ์•ฑ ์ปจํ…Œ์ด๋„ˆ', + environment: 'test', + publicUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL), + checkUrl: normalizeUrl(env.SERVER_COMMAND_TEST_URL), + composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), + serviceName: env.SERVER_COMMAND_TEST_SERVICE, + containerName: 'ai-code-app-app-1', + commandScript: resolveCommandScriptPath('restart-test.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']), + commandWorkingDirectory: mainProjectRoot, + commandEnvironment: { + MAIN_PROJECT_ROOT: mainProjectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_TEST_SERVICE, + SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-app-1', + }, + restartStrategy: 'wait', + }, + { + key: 'rel', + label: 'REL', + summary: 'release ๋ธŒ๋žœ์น˜๋ฅผ ์„œ๋น„์Šคํ•˜๋Š” ๋ฆด๋ฆฌ์ฆˆ ์•ฑ ์ปจํ…Œ์ด๋„ˆ', + environment: 'release', + publicUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL), + checkUrl: normalizeUrl(env.SERVER_COMMAND_REL_URL), + composeFile: path.join(mainProjectRoot, 'docker-compose.yml'), + serviceName: env.SERVER_COMMAND_REL_SERVICE, + containerName: 'ai-code-app-release', + commandScript: resolveCommandScriptPath('restart-rel.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']), + commandWorkingDirectory: mainProjectRoot, + commandEnvironment: { + MAIN_PROJECT_ROOT: mainProjectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_REL_SERVICE, + }, + restartStrategy: 'wait', + }, + { + key: 'work-server', + label: 'WORK-SERVER', + summary: 'Plan, Board, History API๋ฅผ ์ œ๊ณตํ•˜๋Š” ์›Œํฌ์„œ๋ฒ„', + environment: 'internal-api', + publicUrl: null, + checkUrl: normalizeUrl(env.SERVER_COMMAND_WORK_SERVER_URL), + composeFile: path.join(projectRoot, 'etc', 'servers', 'work-server', 'docker-compose.yml'), + serviceName: env.SERVER_COMMAND_WORK_SERVER_SERVICE, + containerName: 'work-server', + commandScript: resolveCommandScriptPath('restart-work-server.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']), + commandWorkingDirectory: projectRoot, + commandEnvironment: { + REPO_ROOT: projectRoot, + }, + restartStrategy: 'deferred', + }, + { + key: 'command-runner', + label: 'COMMAND-RUNNER', + summary: 'nohup์œผ๋กœ ์‹คํ–‰ ์ค‘์ธ ์„œ๋ฒ„ ๋ช…๋ น host runner', + environment: 'host-runner', + publicUrl: null, + checkUrl: normalizeUrl(env.SERVER_COMMAND_RUNNER_URL), + composeFile: path.join(projectRoot, 'scripts', 'run-server-command-runner.mjs'), + serviceName: 'server-command-runner', + containerName: 'server-command-runner', + commandScript: resolveCommandScriptPath('restart-server-command-runner.sh', [projectRoot, mainProjectRoot, '/workspace/main-project']), + commandWorkingDirectory: projectRoot, + commandEnvironment: { + PROJECT_ROOT: projectRoot, + }, + restartStrategy: 'deferred', + }, + ]; +} + +function getServerDefinition(key: ServerCommandKey) { + const definition = getServerDefinitions().find((item) => item.key === key); + + if (!definition) { + throw new Error('์ง€์›ํ•˜์ง€ ์•Š๋Š” ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค.'); + } + + return definition; +} + +function trimPreview(value: string | null | undefined, maxLength = 220) { + const normalized = value?.replace(/\s+/g, ' ').trim() ?? ''; + + if (!normalized) { + return null; + } + + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; +} + +function normalizeDateTimeValue(value: string | null | undefined) { + const normalized = value?.trim(); + + if (!normalized) { + return null; + } + + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function buildRestartCommandPreview(definition: ServerDefinition) { + return `sh ${definition.commandScript}`; +} + +function buildDeferredRestartProbePaths(definition: ServerDefinition) { + const token = `${definition.key}-${Date.now()}-${process.pid}`; + + return { + logPath: path.join('/tmp', `${token}.log`), + statusPath: path.join('/tmp', `${token}.status`), + }; +} + +function buildAcceptedRestartSnapshot(definition: ServerDefinition): ServerCommandSnapshot { + return { + key: definition.key, + label: definition.label, + summary: definition.summary, + environment: definition.environment, + publicUrl: definition.publicUrl, + checkUrl: definition.checkUrl, + composeFile: definition.composeFile, + serviceName: definition.serviceName, + availability: 'degraded', + httpStatus: null, + contentType: null, + responsePreview: null, + checkedAt: new Date().toISOString(), + startedAt: null, + runningVersion: null, + runningBuiltAt: null, + latestVersion: null, + latestBuiltAt: null, + latestSourceChangeAt: null, + latestSourceChangePath: null, + buildRequired: false, + updateAvailable: false, + updateSummary: null, + responseTimeMs: null, + composeStatus: 'restarting', + composeDetails: 'restart requested', + lastCommand: buildRestartCommandPreview(definition), + commandScript: definition.commandScript, + commandWorkingDirectory: definition.commandWorkingDirectory, + errorMessage: null, + }; +} + +function coerceServerSnapshot( + definition: ServerDefinition, + value: unknown, + fallback: ServerCommandSnapshot, +): ServerCommandSnapshot { + if (!value || typeof value !== 'object') { + return fallback; + } + + const item = value as Partial>; + + return { + ...fallback, + key: item.key === definition.key ? definition.key : fallback.key, + label: typeof item.label === 'string' ? item.label : fallback.label, + summary: typeof item.summary === 'string' ? item.summary : fallback.summary, + environment: typeof item.environment === 'string' ? item.environment : fallback.environment, + publicUrl: typeof item.publicUrl === 'string' ? item.publicUrl : item.publicUrl === null ? null : fallback.publicUrl, + checkUrl: typeof item.checkUrl === 'string' ? item.checkUrl : fallback.checkUrl, + composeFile: typeof item.composeFile === 'string' ? item.composeFile : fallback.composeFile, + serviceName: typeof item.serviceName === 'string' ? item.serviceName : fallback.serviceName, + availability: + item.availability === 'online' || item.availability === 'degraded' || item.availability === 'offline' + ? item.availability + : fallback.availability, + httpStatus: typeof item.httpStatus === 'number' ? item.httpStatus : item.httpStatus === null ? null : fallback.httpStatus, + contentType: + typeof item.contentType === 'string' ? item.contentType : item.contentType === null ? null : fallback.contentType, + responsePreview: + typeof item.responsePreview === 'string' + ? item.responsePreview + : item.responsePreview === null + ? null + : fallback.responsePreview, + checkedAt: typeof item.checkedAt === 'string' ? item.checkedAt : fallback.checkedAt, + startedAt: typeof item.startedAt === 'string' ? item.startedAt : item.startedAt === null ? null : fallback.startedAt, + runningVersion: + typeof item.runningVersion === 'string' ? item.runningVersion : item.runningVersion === null ? null : fallback.runningVersion, + runningBuiltAt: + typeof item.runningBuiltAt === 'string' ? item.runningBuiltAt : item.runningBuiltAt === null ? null : fallback.runningBuiltAt, + latestVersion: + typeof item.latestVersion === 'string' ? item.latestVersion : item.latestVersion === null ? null : fallback.latestVersion, + latestBuiltAt: + typeof item.latestBuiltAt === 'string' ? item.latestBuiltAt : item.latestBuiltAt === null ? null : fallback.latestBuiltAt, + latestSourceChangeAt: + typeof item.latestSourceChangeAt === 'string' + ? item.latestSourceChangeAt + : item.latestSourceChangeAt === null + ? null + : fallback.latestSourceChangeAt, + latestSourceChangePath: + typeof item.latestSourceChangePath === 'string' + ? item.latestSourceChangePath + : item.latestSourceChangePath === null + ? null + : fallback.latestSourceChangePath, + buildRequired: typeof item.buildRequired === 'boolean' ? item.buildRequired : fallback.buildRequired, + updateAvailable: typeof item.updateAvailable === 'boolean' ? item.updateAvailable : fallback.updateAvailable, + updateSummary: + typeof item.updateSummary === 'string' ? item.updateSummary : item.updateSummary === null ? null : fallback.updateSummary, + responseTimeMs: + typeof item.responseTimeMs === 'number' ? item.responseTimeMs : item.responseTimeMs === null ? null : fallback.responseTimeMs, + composeStatus: + typeof item.composeStatus === 'string' ? item.composeStatus : item.composeStatus === null ? null : fallback.composeStatus, + composeDetails: + typeof item.composeDetails === 'string' + ? item.composeDetails + : item.composeDetails === null + ? null + : fallback.composeDetails, + lastCommand: typeof item.lastCommand === 'string' ? item.lastCommand : fallback.lastCommand, + commandScript: typeof item.commandScript === 'string' ? item.commandScript : fallback.commandScript, + commandWorkingDirectory: + typeof item.commandWorkingDirectory === 'string' ? item.commandWorkingDirectory : fallback.commandWorkingDirectory, + errorMessage: + typeof item.errorMessage === 'string' ? item.errorMessage : item.errorMessage === null ? null : fallback.errorMessage, + }; +} + +export function buildRestartFailureMessage(label: string, error: unknown) { + const failure = error instanceof Error ? (error as ExecFileFailure) : null; + const output = trimPreview([failure?.stderr, failure?.stdout, failure?.message].filter(Boolean).join('\n'), 400); + const exitInfo = [ + failure?.code != null ? `exit:${String(failure.code)}` : null, + failure?.signal ? `signal:${String(failure.signal)}` : null, + ] + .filter(Boolean) + .join(' '); + + return trimPreview( + [`${label} ์žฌ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`, exitInfo || null, output || null].filter(Boolean).join(' '), + 400, + ) || `${label} ์žฌ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`; +} + +async function waitForDeferredRestartResult( + definition: ServerDefinition, + statusPath: string, + logPath: string, +): Promise { + const deadline = Date.now() + DEFERRED_RESTART_CONFIRM_TIMEOUT_MS; + + while (Date.now() <= deadline) { + try { + const statusText = (await readFile(statusPath, 'utf8')).trim(); + const output = trimPreview(await readFile(logPath, 'utf8'), 400); + const exitCode = Number.parseInt(statusText, 10); + + await rm(statusPath, { force: true }); + await rm(logPath, { force: true }); + + if (!Number.isNaN(exitCode) && exitCode !== 0) { + const restartError = new Error( + buildRestartFailureMessage( + definition.label, + Object.assign(new Error(`deferred restart exited with ${exitCode}`), { + code: exitCode, + stderr: output ?? '', + stdout: '', + }), + ), + ); + (restartError as Error & { statusCode?: number }).statusCode = 500; + throw restartError; + } + + return output; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ) { + await new Promise((resolve) => { + setTimeout(resolve, DEFERRED_RESTART_POLL_INTERVAL_MS); + }); + continue; + } + + throw error; + } + } + + return null; +} + +async function restartServerCommandDeferred(definition: ServerDefinition): Promise { + const { logPath, statusPath } = buildDeferredRestartProbePaths(definition); + const shellCommand = [ + `sleep ${Math.ceil(DEFERRED_RESTART_DELAY_MS / 1000)}`, + `sh ${JSON.stringify(definition.commandScript)} >${JSON.stringify(logPath)} 2>&1`, + 'status=$?', + `printf '%s' \"$status\" >${JSON.stringify(statusPath)}`, + ].join('; '); + + await new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', shellCommand], { + cwd: definition.commandWorkingDirectory, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + ...definition.commandEnvironment, + }, + }); + + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); + + const commandOutput = await waitForDeferredRestartResult(definition, statusPath, logPath); + + return { + server: buildAcceptedRestartSnapshot(definition), + commandOutput: commandOutput ?? `${definition.label} ์žฌ๊ธฐ๋™ ์š”์ฒญ์„ ์ ‘์ˆ˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.`, + restartState: 'accepted', + }; +} + +async function parseRemoteRestartPayload(response: Response): Promise { + const responseText = await response.text(); + const parsed = + responseText.trim().length > 0 + ? (() => { + try { + return JSON.parse(responseText) as { + item?: unknown; + server?: unknown; + commandOutput?: unknown; + output?: unknown; + restartState?: unknown; + data?: { + item?: unknown; + server?: unknown; + commandOutput?: unknown; + output?: unknown; + restartState?: unknown; + }; + }; + } catch { + return null; + } + })() + : null; + const nestedData = parsed?.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data) ? parsed.data : null; + + return { + server: + parsed?.item && typeof parsed.item === 'object' + ? (parsed.item as ServerCommandSnapshot) + : parsed?.server && typeof parsed.server === 'object' + ? (parsed.server as ServerCommandSnapshot) + : nestedData?.item && typeof nestedData.item === 'object' + ? (nestedData.item as ServerCommandSnapshot) + : nestedData?.server && typeof nestedData.server === 'object' + ? (nestedData.server as ServerCommandSnapshot) + : undefined, + commandOutput: trimPreview( + [ + typeof parsed?.commandOutput === 'string' ? parsed.commandOutput : null, + typeof parsed?.output === 'string' ? parsed.output : null, + typeof nestedData?.commandOutput === 'string' ? nestedData.commandOutput : null, + typeof nestedData?.output === 'string' ? nestedData.output : null, + !parsed ? responseText : null, + ] + .filter(Boolean) + .join('\n'), + 400, + ), + restartState: + parsed?.restartState === 'accepted' || nestedData?.restartState === 'accepted' ? 'accepted' : 'completed', + }; +} + +async function restartServerCommandViaApi(definition: ServerDefinition): Promise { + const apiBaseUrl = normalizeOptionalUrl(env.SERVER_COMMAND_API_BASE_URL); + if (!apiBaseUrl) { + throw new Error('SERVER_COMMAND_API_BASE_URL์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + const requestUrl = buildServerCommandApiRestartUrl( + apiBaseUrl, + env.SERVER_COMMAND_API_RESTART_PATH_TEMPLATE, + definition.key, + ); + const headers = new Headers(); + + if (env.SERVER_COMMAND_API_ACCESS_TOKEN?.trim()) { + headers.set('X-Access-Token', env.SERVER_COMMAND_API_ACCESS_TOKEN.trim()); + } + + let response: Response; + + try { + response = await fetch(requestUrl, { + method: 'POST', + headers, + signal: AbortSignal.timeout(15000), + }); + } catch (error) { + const restartError = new Error(buildRestartFailureMessage(definition.label, error)); + (restartError as Error & { statusCode?: number }).statusCode = 500; + throw restartError; + } + + if (!response.ok) { + const detail = await response.text(); + const failure = Object.assign(new Error(`HTTP ${response.status}`), { + code: response.status, + stderr: detail, + stdout: '', + }); + const restartError = new Error(buildRestartFailureMessage(definition.label, failure)); + (restartError as Error & { statusCode?: number }).statusCode = 500; + throw restartError; + } + + const remotePayload = await parseRemoteRestartPayload(response); + const fallbackSnapshot = + remotePayload.restartState === 'accepted' ? buildAcceptedRestartSnapshot(definition) : await checkServer(definition); + + return { + server: coerceServerSnapshot(definition, remotePayload.server, fallbackSnapshot), + commandOutput: remotePayload.commandOutput, + restartState: remotePayload.restartState, + }; +} + +function shouldFallbackFromRemoteRestart(error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + + return /Failed to fetch|fetch failed|ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|404|408/i.test(detail); +} + +async function inspectComposeStatus(definition: ServerDefinition) { + try { + const { stdout } = await execFileAsync( + 'docker', + ['compose', '-f', definition.composeFile, 'ps', definition.serviceName, '--format', 'json'], + { + cwd: definition.commandWorkingDirectory, + timeout: 8000, + maxBuffer: 1024 * 1024, + }, + ); + const normalized = stdout.trim(); + + if (!normalized) { + return { + startedAt: null, + composeStatus: null, + composeDetails: null, + }; + } + + const parsed = JSON.parse(normalized) as Record | Array>; + const firstRow = Array.isArray(parsed) ? parsed[0] : parsed; + const status = typeof firstRow?.State === 'string' ? firstRow.State : typeof firstRow?.Status === 'string' ? firstRow.Status : null; + const details = trimPreview( + [ + typeof firstRow?.Name === 'string' ? `name:${firstRow.Name}` : null, + typeof firstRow?.Publishers === 'string' ? `publishers:${firstRow.Publishers}` : null, + typeof firstRow?.Health === 'string' ? `health:${firstRow.Health}` : null, + ] + .filter(Boolean) + .join(' '), + ); + + return { + startedAt: null, + composeStatus: status, + composeDetails: details, + }; + } catch (error) { + return { + startedAt: null, + composeStatus: null, + composeDetails: trimPreview(error instanceof Error ? error.message : 'compose ์ƒํƒœ ํ™•์ธ ์‹คํŒจ'), + }; + } +} + +async function inspectContainerRuntime(definition: ServerDefinition): Promise { + try { + const { stdout } = await execFileAsync( + 'docker', + ['inspect', '-f', '{{.State.StartedAt}}\t{{.State.Status}}\t{{.Name}}', definition.containerName], + { + cwd: definition.commandWorkingDirectory, + timeout: 8000, + maxBuffer: 1024 * 1024, + }, + ); + const [startedAtRaw = '', statusRaw = '', nameRaw = ''] = stdout.trim().split('\t'); + + return { + startedAt: normalizeDateTimeValue(startedAtRaw), + composeStatus: statusRaw.trim() || null, + composeDetails: trimPreview(nameRaw.trim() ? `name:${nameRaw.trim().replace(/^\//, '')}` : null), + }; + } catch (error) { + return inspectComposeStatus(definition); + } +} + +async function inspectRunnerRuntime(definition: ServerDefinition): Promise { + try { + const runnerScriptName = path.basename(path.join(definition.commandWorkingDirectory, 'scripts', 'run-server-command-runner.mjs')); + const { stdout } = await execFileAsync( + 'sh', + [ + '-c', + `ps -eo lstart=,args= | grep ${JSON.stringify(runnerScriptName)} | grep -v grep | head -n 1`, + ], + { + cwd: definition.commandWorkingDirectory, + timeout: 5000, + maxBuffer: 1024 * 1024, + }, + ); + const line = stdout.trim(); + + if (!line) { + return { + startedAt: null, + composeStatus: null, + composeDetails: null, + }; + } + + const match = line.match(/^([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/); + const startedAt = normalizeDateTimeValue(match?.[1] ?? null); + + return { + startedAt, + composeStatus: 'running', + composeDetails: trimPreview(match?.[2] ?? line), + }; + } catch { + return { + startedAt: null, + composeStatus: null, + composeDetails: null, + }; + } +} + +async function inspectRunnerHeartbeat(): Promise { + const heartbeatCandidates = [ + env.SERVER_COMMAND_RUNNER_HEARTBEAT_FILE?.trim() || null, + path.join(normalizePath(env.SERVER_COMMAND_PROJECT_ROOT), '.server-command-runner-heartbeat.json'), + path.join(normalizePath(env.SERVER_COMMAND_MAIN_PROJECT_ROOT), '.server-command-runner-heartbeat.json'), + ].filter((value, index, array): value is string => Boolean(value) && array.indexOf(value) === index); + + let latestHeartbeatPath: string | null = null; + let latestHeartbeatStat: Awaited> | null = null; + + for (const candidate of heartbeatCandidates) { + try { + const candidateStat = await stat(candidate); + + if (!latestHeartbeatStat || candidateStat.mtimeMs > latestHeartbeatStat.mtimeMs) { + latestHeartbeatPath = candidate; + latestHeartbeatStat = candidateStat; + } + } catch { + continue; + } + } + + const heartbeatPath = latestHeartbeatPath ?? heartbeatCandidates[0] ?? '.server-command-runner-heartbeat.json'; + + try { + const heartbeatStat = latestHeartbeatStat ?? (await stat(heartbeatPath)); + const ageMs = Date.now() - heartbeatStat.mtimeMs; + const startedAt = normalizeDateTimeValue(heartbeatStat.birthtime.toISOString()); + + if (ageMs <= RUNNER_HEARTBEAT_FRESHNESS_MS) { + return { + startedAt, + composeStatus: 'running', + composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), + availability: 'online', + responsePreview: trimPreview(`heartbeat ok ยท ${Math.max(0, Math.round(ageMs / 1000))}์ดˆ ์ „ ๊ฐฑ์‹ `), + errorMessage: '๋กœ์ปฌ ์ „์šฉ runner๋ฅผ heartbeat ํŒŒ์ผ ๊ธฐ์ค€์œผ๋กœ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + return { + startedAt, + composeStatus: 'stale', + composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), + availability: 'offline', + responsePreview: trimPreview(`heartbeat stale ยท ${Math.max(0, Math.round(ageMs / 1000))}์ดˆ ๊ฒฝ๊ณผ`), + errorMessage: 'runner heartbeat ๊ฐฑ์‹ ์ด ์˜ค๋ž˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + }; + } catch (error) { + return { + startedAt: null, + composeStatus: null, + composeDetails: trimPreview(`heartbeat:${heartbeatPath}`), + availability: 'offline', + responsePreview: null, + errorMessage: error instanceof Error ? `runner heartbeat ํ™•์ธ ์‹คํŒจ: ${error.message}` : 'runner heartbeat ํ™•์ธ ์‹คํŒจ', + }; + } +} + +function inspectCurrentProcessRuntime(): RuntimeInspectionResult { + const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString(); + + return { + startedAt, + composeStatus: 'running', + composeDetails: trimPreview(`pid:${process.pid}`), + }; +} + +async function inspectRuntime(definition: ServerDefinition): Promise { + if (definition.key === 'command-runner') { + const runtimeInfo = await inspectRunnerRuntime(definition); + + if (runtimeInfo.startedAt) { + return runtimeInfo; + } + + return inspectRunnerHeartbeat(); + } + + if (definition.key === 'work-server') { + const runtimeInfo = await inspectContainerRuntime(definition); + + if (runtimeInfo.startedAt) { + return runtimeInfo; + } + + return inspectCurrentProcessRuntime(); + } + + return inspectContainerRuntime(definition); +} + +async function inspectBuild(definition: ServerDefinition): Promise { + if (definition.key !== 'work-server') { + return { + runningVersion: null, + runningBuiltAt: null, + latestVersion: null, + latestBuiltAt: null, + latestSourceChangeAt: null, + latestSourceChangePath: null, + buildRequired: false, + updateAvailable: false, + updateSummary: null, + }; + } + + const runningBuild = getRuntimeWorkServerBuildInfo(); + const latestBuild = await readLatestWorkServerBuildInfo(); + const latestSourceChange = await readLatestWorkServerSourceChange(); + const latestSourceChangedAt = latestSourceChange?.changedAt ?? null; + const buildRequired = latestSourceChangedAt + ? !latestBuild?.builtAt || latestSourceChangedAt > latestBuild.builtAt + : false; + const updateAvailable = + Boolean(runningBuild?.buildId) && Boolean(latestBuild?.buildId) && runningBuild?.buildId !== latestBuild?.buildId; + + return { + runningVersion: runningBuild?.buildId ?? null, + runningBuiltAt: runningBuild?.builtAt ?? null, + latestVersion: latestBuild?.buildId ?? null, + latestBuiltAt: latestBuild?.builtAt ?? null, + latestSourceChangeAt: latestSourceChangedAt, + latestSourceChangePath: latestSourceChange?.path ?? null, + buildRequired, + updateAvailable, + updateSummary: buildRequired + ? `์ˆ˜์ •๋œ ์†Œ์Šค๊ฐ€ ์ตœ์‹  ๋นŒ๋“œ๋ณด๋‹ค ์ƒˆ๋กญ์Šต๋‹ˆ๋‹ค.${latestSourceChange?.path ? ` (${latestSourceChange?.path})` : ''} ์žฌ์‹œ์ž‘ ์‹œ ๋‹ค์‹œ ๋นŒ๋“œํ•œ ๋’ค ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.` + : updateAvailable + ? '์ƒˆ๋กœ์šด work-server ๋นŒ๋“œ๊ฐ€ ์ค€๋น„๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์žฌ์‹œ์ž‘ํ•˜๋ฉด ์ตœ์‹  ๋ฒ„์ „์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.' + : runningBuild && latestBuild + ? '์‹คํ–‰ ์ค‘์ธ work-server๊ฐ€ ์ตœ์‹  ๋นŒ๋“œ์ž…๋‹ˆ๋‹ค.' + : latestBuild + ? '์ตœ์‹  ๋นŒ๋“œ๋Š” ์ค€๋น„๋˜์–ด ์žˆ์ง€๋งŒ ์‹คํ–‰ ์ค‘ ๋ฒ„์ „ ์ •๋ณด๋ฅผ ์ฝ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.' + : '์•„์ง ํ™•์ธ๋œ work-server ๋นŒ๋“œ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', + }; +} + +async function checkServer(definition: ServerDefinition): Promise { + const startedAt = Date.now(); + const attemptUrls = buildHealthCheckUrls(definition.key, definition.checkUrl); + const attempts: HealthCheckAttempt[] = []; + let selectedAttempt: HealthCheckAttempt | null = null; + + for (const attemptUrl of attemptUrls) { + const attempt = await fetchHealthCheck(attemptUrl); + attempts.push(attempt); + + if (attempt.availability !== 'offline') { + selectedAttempt = attempt; + break; + } + } + + if (!selectedAttempt) { + selectedAttempt = attempts[0] ?? { + url: definition.checkUrl, + httpStatus: null, + contentType: null, + responsePreview: null, + availability: 'offline', + errorMessage: '์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + + const runtimeInfo = await inspectRuntime(definition); + const buildInfo = await inspectBuild(definition); + const fallbackAttempt = selectedAttempt.url !== definition.checkUrl ? `fallback health check succeeded via ${selectedAttempt.url}` : null; + const collectedErrors = attempts + .filter((attempt) => attempt.errorMessage) + .map((attempt) => `${attempt.url} -> ${attempt.errorMessage}`); + const errorMessage = + runtimeInfo.errorMessage ?? + selectedAttempt.errorMessage ?? + (fallbackAttempt && collectedErrors.length > 0 + ? trimPreview([fallbackAttempt, ...collectedErrors].join(' | '), 400) + : collectedErrors.length > 0 + ? trimPreview(collectedErrors.join(' | '), 400) + : fallbackAttempt); + + return { + key: definition.key, + label: definition.label, + summary: definition.summary, + environment: definition.environment, + publicUrl: definition.publicUrl, + checkUrl: definition.checkUrl, + composeFile: definition.composeFile, + serviceName: definition.serviceName, + availability: runtimeInfo.availability ?? selectedAttempt.availability, + httpStatus: selectedAttempt.httpStatus, + contentType: selectedAttempt.contentType, + responsePreview: runtimeInfo.responsePreview ?? selectedAttempt.responsePreview, + checkedAt: new Date().toISOString(), + startedAt: runtimeInfo.startedAt, + runningVersion: buildInfo.runningVersion, + runningBuiltAt: buildInfo.runningBuiltAt, + latestVersion: buildInfo.latestVersion, + latestBuiltAt: buildInfo.latestBuiltAt, + latestSourceChangeAt: buildInfo.latestSourceChangeAt, + latestSourceChangePath: buildInfo.latestSourceChangePath, + buildRequired: buildInfo.buildRequired, + updateAvailable: buildInfo.updateAvailable, + updateSummary: buildInfo.updateSummary, + responseTimeMs: Date.now() - startedAt, + composeStatus: runtimeInfo.composeStatus, + composeDetails: runtimeInfo.composeDetails, + lastCommand: buildRestartCommandPreview(definition), + commandScript: definition.commandScript, + commandWorkingDirectory: definition.commandWorkingDirectory, + errorMessage, + }; +} + +export async function listServerCommands() { + const definitions = getServerDefinitions(); + return Promise.all(definitions.map((definition) => checkServer(definition))); +} + +export async function restartServerCommand(key: ServerCommandKey): Promise { + const definition = getServerDefinition(key); + if (normalizeOptionalUrl(env.SERVER_COMMAND_API_BASE_URL)) { + try { + return await restartServerCommandViaApi(definition); + } catch (error) { + if (!shouldFallbackFromRemoteRestart(error)) { + throw error; + } + } + } + + let stdout = ''; + let stderr = ''; + + if (definition.restartStrategy === 'deferred') { + return restartServerCommandDeferred(definition); + } + + try { + const commandResult = await execFileAsync('sh', [definition.commandScript], { + cwd: definition.commandWorkingDirectory, + timeout: 30000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + ...definition.commandEnvironment, + }, + }); + stdout = commandResult.stdout; + stderr = commandResult.stderr; + } catch (error) { + if (shouldRetryWithDockerSocket(error)) { + try { + const commandResult = await restartViaDockerSocket(definition); + stdout = commandResult.stdout; + stderr = commandResult.stderr; + } catch (socketError) { + const restartError = new Error(buildRestartFailureMessage(definition.label, socketError)); + (restartError as Error & { statusCode?: number }).statusCode = 500; + throw restartError; + } + } else { + const restartError = new Error(buildRestartFailureMessage(definition.label, error)); + (restartError as Error & { statusCode?: number }).statusCode = 500; + throw restartError; + } + } + + const server = await checkServer(definition); + + return { + server, + commandOutput: trimPreview([stdout, stderr].filter(Boolean).join('\n'), 400), + restartState: 'completed', + }; +} diff --git a/etc/servers/work-server/src/services/visitor-history-service.ts b/etc/servers/work-server/src/services/visitor-history-service.ts new file mode 100755 index 0000000..49b6675 --- /dev/null +++ b/etc/servers/work-server/src/services/visitor-history-service.ts @@ -0,0 +1,353 @@ +import { z } from 'zod'; +import { db } from '../db/client.js'; + +export const VISITOR_CLIENT_TABLE = 'visitor_clients'; +export const VISITOR_HISTORY_TABLE = 'visitor_visit_histories'; + +export const trackVisitSchema = z.object({ + clientId: z.string().trim().min(1).max(120), + url: z.string().trim().min(1).max(2000), + eventType: z.string().trim().min(1).max(80).default('page_view'), + userAgent: z.string().trim().max(1000).optional().nullable(), +}); + +export const listVisitorClientsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), + search: z.string().trim().max(120).optional(), + clientId: z.string().trim().max(120).optional(), + nickname: z.string().trim().max(80).optional(), + path: z.string().trim().max(500).optional(), + visitedFrom: z.string().trim().max(40).optional(), + visitedTo: z.string().trim().max(40).optional(), +}); + +export const visitorHistoryQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), +}); + +export const updateVisitorNicknameSchema = z.object({ + nickname: z.string().trim().min(1).max(80), +}); + +export type VisitorClientItem = { + clientId: string; + nickname: string; + firstVisitedAt: string; + lastVisitedAt: string; + visitCount: number; + lastVisitedUrl: string | null; + lastUserAgent: string | null; + lastIp: string | null; + createdAt: string; + updatedAt: string; +}; + +export type VisitorHistoryItem = { + id: number; + clientId: string; + visitedAt: string; + url: string; + eventType: string; + userAgent: string | null; + ip: string | null; +}; + +function mapVisitorClientRow(row: Record): VisitorClientItem { + return { + clientId: String(row.client_id ?? ''), + nickname: String(row.nickname ?? ''), + firstVisitedAt: String(row.first_visited_at ?? ''), + lastVisitedAt: String(row.last_visited_at ?? ''), + visitCount: Number(row.visit_count ?? 0), + lastVisitedUrl: row.last_visited_url ? String(row.last_visited_url) : null, + lastUserAgent: row.last_user_agent ? String(row.last_user_agent) : null, + lastIp: row.last_ip ? String(row.last_ip) : null, + createdAt: String(row.created_at ?? ''), + updatedAt: String(row.updated_at ?? ''), + }; +} + +function mapVisitorHistoryRow(row: Record): VisitorHistoryItem { + return { + id: Number(row.id ?? 0), + clientId: String(row.client_id ?? ''), + visitedAt: String(row.visited_at ?? ''), + url: String(row.url ?? ''), + eventType: String(row.event_type ?? 'page_view'), + userAgent: row.user_agent ? String(row.user_agent) : null, + ip: row.ip ? String(row.ip) : null, + }; +} + +function normalizeDateBoundary(value: string, boundary: 'start' | 'end') { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + return value; + } + + return boundary === 'start' ? `${value} 00:00:00` : `${value} 23:59:59.999`; +} + +async function ensureVisitorClientTable() { + const hasTable = await db.schema.hasTable(VISITOR_CLIENT_TABLE); + + if (!hasTable) { + await db.schema.createTable(VISITOR_CLIENT_TABLE, (table) => { + table.increments('id').primary(); + table.string('client_id', 120).notNullable().unique(); + table.string('nickname', 80).notNullable(); + table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.integer('visit_count').notNullable().defaultTo(1); + table.string('last_visited_url', 2000).nullable(); + table.string('last_user_agent', 1000).nullable(); + table.string('last_ip', 120).nullable(); + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['client_id', (table) => table.string('client_id', 120).notNullable().unique()], + ['nickname', (table) => table.string('nickname', 80).notNullable().defaultTo('๋ฐฉ๋ฌธ์ž_0001')], + ['first_visited_at', (table) => table.timestamp('first_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['last_visited_at', (table) => table.timestamp('last_visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['visit_count', (table) => table.integer('visit_count').notNullable().defaultTo(1)], + ['last_visited_url', (table) => table.string('last_visited_url', 2000).nullable()], + ['last_user_agent', (table) => table.string('last_user_agent', 1000).nullable()], + ['last_ip', (table) => table.string('last_ip', 120).nullable()], + ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(VISITOR_CLIENT_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(VISITOR_CLIENT_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +async function ensureVisitorHistoryTable() { + const hasTable = await db.schema.hasTable(VISITOR_HISTORY_TABLE); + + if (!hasTable) { + await db.schema.createTable(VISITOR_HISTORY_TABLE, (table) => { + table.increments('id').primary(); + table.string('client_id', 120).notNullable().index(); + table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.string('url', 2000).notNullable(); + table.string('event_type', 80).notNullable().defaultTo('page_view'); + table.string('user_agent', 1000).nullable(); + table.string('ip', 120).nullable(); + }); + + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['client_id', (table) => table.string('client_id', 120).notNullable().index()], + ['visited_at', (table) => table.timestamp('visited_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ['url', (table) => table.string('url', 2000).notNullable().defaultTo('')], + ['event_type', (table) => table.string('event_type', 80).notNullable().defaultTo('page_view')], + ['user_agent', (table) => table.string('user_agent', 1000).nullable()], + ['ip', (table) => table.string('ip', 120).nullable()], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(VISITOR_HISTORY_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(VISITOR_HISTORY_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function ensureVisitorHistoryTables() { + await ensureVisitorClientTable(); + await ensureVisitorHistoryTable(); +} + +async function generateAutoNickname() { + const result = await db.raw<{ rows?: Array<{ next_nickname_number: number | string | null }> }>( + ` + select coalesce(max(cast(substring(nickname from '[0-9]+$') as integer)), 0) + 1 as next_nickname_number + from ${VISITOR_CLIENT_TABLE} + where nickname like '๋ฐฉ๋ฌธ์ž\\_%' + `, + ); + + const nextNumber = Number(result.rows?.[0]?.next_nickname_number ?? 1); + return `๋ฐฉ๋ฌธ์ž_${String(nextNumber).padStart(4, '0')}`; +} + +export async function getVisitorClientByClientId(clientId: string) { + await ensureVisitorHistoryTables(); + const row = await db(VISITOR_CLIENT_TABLE).where({ client_id: clientId }).first(); + return row ? mapVisitorClientRow(row) : null; +} + +export type VisitorClientListFilters = { + search?: string; + clientId?: string; + nickname?: string; + path?: string; + visitedFrom?: string; + visitedTo?: string; +}; + +export async function listVisitorClients(limit = 100, filters: VisitorClientListFilters = {}) { + await ensureVisitorHistoryTables(); + + const query = db(VISITOR_CLIENT_TABLE) + .select('*') + .orderBy('last_visited_at', 'desc') + .limit(Math.min(Math.max(Math.trunc(limit), 1), 500)); + + const normalizedSearch = filters.search?.trim() ?? ''; + if (normalizedSearch) { + query.where((builder) => { + builder.whereILike('client_id', `%${normalizedSearch}%`).orWhereILike('nickname', `%${normalizedSearch}%`); + }); + } + + const normalizedClientId = filters.clientId?.trim() ?? ''; + if (normalizedClientId) { + query.whereILike('client_id', `%${normalizedClientId}%`); + } + + const normalizedNickname = filters.nickname?.trim() ?? ''; + if (normalizedNickname) { + query.whereILike('nickname', `%${normalizedNickname}%`); + } + + const normalizedPath = filters.path?.trim() ?? ''; + const normalizedVisitedFrom = filters.visitedFrom?.trim() ?? ''; + const normalizedVisitedTo = filters.visitedTo?.trim() ?? ''; + + if (normalizedPath || normalizedVisitedFrom || normalizedVisitedTo) { + query.whereIn( + 'client_id', + db(VISITOR_HISTORY_TABLE) + .select('client_id') + .modify((historyQuery) => { + if (normalizedPath) { + historyQuery.whereILike('url', `%${normalizedPath}%`); + } + + if (normalizedVisitedFrom) { + historyQuery.where('visited_at', '>=', normalizeDateBoundary(normalizedVisitedFrom, 'start')); + } + + if (normalizedVisitedTo) { + historyQuery.where('visited_at', '<=', normalizeDateBoundary(normalizedVisitedTo, 'end')); + } + }), + ); + } + + const rows = await query; + return rows.map((row) => mapVisitorClientRow(row)); +} + +export async function listVisitorHistories(clientId: string, limit = 100) { + await ensureVisitorHistoryTables(); + + const rows = await db(VISITOR_HISTORY_TABLE) + .select('*') + .where({ client_id: clientId }) + .orderBy('visited_at', 'desc') + .limit(Math.min(Math.max(Math.trunc(limit), 1), 500)); + + return rows.map((row) => mapVisitorHistoryRow(row)); +} + +export async function updateVisitorNickname(clientId: string, nickname: string) { + await ensureVisitorHistoryTables(); + + const rows = await db(VISITOR_CLIENT_TABLE) + .where({ client_id: clientId }) + .update({ + nickname, + updated_at: db.fn.now(), + }) + .returning('*'); + + const row = rows[0]; + return row ? mapVisitorClientRow(row) : null; +} + +export async function trackVisit(payload: z.infer, ip: string | null | undefined) { + await ensureVisitorHistoryTables(); + const parsedPayload = trackVisitSchema.parse(payload); + const normalizedIp = String(ip ?? '').trim() || null; + const normalizedUserAgent = parsedPayload.userAgent?.trim() || null; + + const existing = await db(VISITOR_CLIENT_TABLE) + .select('*') + .where({ client_id: parsedPayload.clientId }) + .first(); + + if (existing) { + await db(VISITOR_CLIENT_TABLE) + .where({ client_id: parsedPayload.clientId }) + .update({ + last_visited_at: db.fn.now(), + visit_count: db.raw('coalesce(visit_count, 0) + 1'), + last_visited_url: parsedPayload.url, + last_user_agent: normalizedUserAgent, + last_ip: normalizedIp, + updated_at: db.fn.now(), + }); + } else { + try { + // ์ตœ์ดˆ ๋ฐฉ๋ฌธ๋งŒ ์ž๋™ ๋‹‰๋„ค์ž„์„ ๋ฐœ๊ธ‰ํ•˜๊ณ  ์ดํ›„์—๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + await db(VISITOR_CLIENT_TABLE).insert({ + client_id: parsedPayload.clientId, + nickname: await generateAutoNickname(), + first_visited_at: db.fn.now(), + last_visited_at: db.fn.now(), + visit_count: 1, + last_visited_url: parsedPayload.url, + last_user_agent: normalizedUserAgent, + last_ip: normalizedIp, + created_at: db.fn.now(), + updated_at: db.fn.now(), + }); + } catch (error) { + const dbError = error as { code?: string }; + + if (dbError.code !== '23505') { + throw error; + } + + await db(VISITOR_CLIENT_TABLE) + .where({ client_id: parsedPayload.clientId }) + .update({ + last_visited_at: db.fn.now(), + visit_count: db.raw('coalesce(visit_count, 0) + 1'), + last_visited_url: parsedPayload.url, + last_user_agent: normalizedUserAgent, + last_ip: normalizedIp, + updated_at: db.fn.now(), + }); + } + } + + await db(VISITOR_HISTORY_TABLE).insert({ + client_id: parsedPayload.clientId, + visited_at: db.fn.now(), + url: parsedPayload.url, + event_type: parsedPayload.eventType, + user_agent: normalizedUserAgent, + ip: normalizedIp, + }); + + return getVisitorClientByClientId(parsedPayload.clientId); +} diff --git a/etc/servers/work-server/src/services/work-server-build-service.ts b/etc/servers/work-server/src/services/work-server-build-service.ts new file mode 100755 index 0000000..92d02ab --- /dev/null +++ b/etc/servers/work-server/src/services/work-server-build-service.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export type WorkServerBuildInfo = { + version: string; + buildId: string; + builtAt: string; +}; + +export type WorkServerSourceChangeInfo = { + changedAt: string; + path: string; +}; + +const MODULE_DIR_PATH = path.dirname(fileURLToPath(import.meta.url)); +const WORK_SERVER_ROOT_PATH = path.resolve(MODULE_DIR_PATH, '..', '..'); +const BUILD_INFO_FILE_PATH = path.join(WORK_SERVER_ROOT_PATH, 'dist', 'build-info.json'); +const SOURCE_TARGET_PATHS = [ + path.join(WORK_SERVER_ROOT_PATH, 'src'), + path.join(WORK_SERVER_ROOT_PATH, 'scripts'), + path.join(WORK_SERVER_ROOT_PATH, 'package.json'), + path.join(WORK_SERVER_ROOT_PATH, 'tsconfig.json'), +] as const; + +function normalizeBuildInfo(value: unknown): WorkServerBuildInfo | null { + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Partial>; + + if ( + typeof candidate.version !== 'string' || + typeof candidate.buildId !== 'string' || + typeof candidate.builtAt !== 'string' + ) { + return null; + } + + const builtAt = new Date(candidate.builtAt); + + if (Number.isNaN(builtAt.getTime())) { + return null; + } + + return { + version: candidate.version, + buildId: candidate.buildId, + builtAt: builtAt.toISOString(), + }; +} + +function readBuildInfoFromDiskSync(filePath: string) { + try { + if (!fs.existsSync(filePath)) { + return null; + } + + return normalizeBuildInfo(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch { + return null; + } +} + +export async function readLatestWorkServerBuildInfo() { + try { + return normalizeBuildInfo(JSON.parse(await readFile(BUILD_INFO_FILE_PATH, 'utf8'))); + } catch { + return null; + } +} + +const runtimeWorkServerBuildInfo = readBuildInfoFromDiskSync(BUILD_INFO_FILE_PATH); + +export function getRuntimeWorkServerBuildInfo() { + return runtimeWorkServerBuildInfo; +} + +async function findLatestSourceChangeInPath(targetPath: string): Promise { + try { + const stats = await fs.promises.stat(targetPath); + + if (stats.isFile()) { + return { + changedAt: stats.mtime.toISOString(), + path: path.relative(WORK_SERVER_ROOT_PATH, targetPath) || path.basename(targetPath), + }; + } + + if (!stats.isDirectory()) { + return null; + } + + const entries = await fs.promises.readdir(targetPath, { withFileTypes: true }); + let latest: WorkServerSourceChangeInfo | null = null; + + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.docker') { + continue; + } + + const childPath = path.join(targetPath, entry.name); + const childLatest = await findLatestSourceChangeInPath(childPath); + + if (!childLatest) { + continue; + } + + if (!latest || childLatest.changedAt > latest.changedAt) { + latest = childLatest; + } + } + + return latest; + } catch { + return null; + } +} + +export async function readLatestWorkServerSourceChange() { + let latest: WorkServerSourceChangeInfo | null = null; + + for (const targetPath of SOURCE_TARGET_PATHS) { + const candidate = await findLatestSourceChangeInPath(targetPath); + + if (!candidate) { + continue; + } + + if (!latest || candidate.changedAt > latest.changedAt) { + latest = candidate; + } + } + + return latest; +} diff --git a/etc/servers/work-server/src/services/worklog-automation-service.test.ts b/etc/servers/work-server/src/services/worklog-automation-service.test.ts new file mode 100755 index 0000000..d2cf316 --- /dev/null +++ b/etc/servers/work-server/src/services/worklog-automation-service.test.ts @@ -0,0 +1,119 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ensureDailyWorklogFile, + isWorklogCreationDue, + normalizeDailyCreateTime, + upgradeLegacyWorklogContent, +} from './worklog-automation-utils.js'; + +test('normalizeDailyCreateTime falls back when value is invalid', () => { + assert.equal(normalizeDailyCreateTime(undefined), '18:00'); + assert.equal(normalizeDailyCreateTime('bad'), '18:00'); + assert.equal(normalizeDailyCreateTime('09:30'), '09:30'); +}); + +test('isWorklogCreationDue checks KST schedule once per day', () => { + assert.equal(isWorklogCreationDue(new Date('2026-04-07T08:59:00Z'), '18:00', false), false); + assert.equal(isWorklogCreationDue(new Date('2026-04-07T09:00:00Z'), '18:00', false), true); + assert.equal(isWorklogCreationDue(new Date('2026-04-07T09:05:00Z'), '18:00', true), false); +}); + +test('ensureDailyWorklogFile creates today worklog from template only once', async () => { + const repoPath = await mkdtemp(path.join(tmpdir(), 'worklog-automation-test-')); + const templatePath = path.join(repoPath, 'docs', 'templates', 'worklog-template.md'); + + try { + await mkdir(path.dirname(templatePath), { recursive: true }); + await writeFile(templatePath, '# YYYY-MM-DD ์ž‘์—…์ผ์ง€\n\n## ์˜ค๋Š˜ ์ž‘์—…\n\n- \n', 'utf8'); + + const worklogPath = await ensureDailyWorklogFile(repoPath, '2026-04-07'); + const firstContent = await readFile(worklogPath, 'utf8'); + assert.equal(firstContent.includes('# 2026-04-07 ์ž‘์—…์ผ์ง€'), true); + + await writeFile(worklogPath, '# keep\n', 'utf8'); + await ensureDailyWorklogFile(repoPath, '2026-04-07'); + const secondContent = await readFile(worklogPath, 'utf8'); + assert.equal(secondContent, '# keep\n'); + } finally { + await rm(repoPath, { recursive: true, force: true }); + } +}); + +test('upgradeLegacyWorklogContent replaces legacy source placeholder with file scoped format', () => { + const legacyContent = `# 2026-04-07 ์ž‘์—…์ผ์ง€ + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- + +## ์†Œ์Šค + +- ์š”์•ฝ ์„ค๋ช… +- \`path/to/file.tsx\`: ๋ณ€๊ฒฝ ๋˜๋Š” ์‹ ๊ทœ ์ถ”๊ฐ€ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ + +\`\`\`diff +# ํ•ต์‹ฌ diff๋ฅผ 1~3๊ฐœ ๋ธ”๋ก์œผ๋กœ ๊ธฐ๋ก +- before ++ after +\`\`\` +`; + + const upgraded = upgradeLegacyWorklogContent(legacyContent); + assert.equal(upgraded.includes('### ํŒŒ์ผ 1: `path/to/file.tsx`'), true); + assert.equal(upgraded.includes('# ์ด ํŒŒ์ผ์˜ raw diff'), true); + assert.equal(upgraded.includes('`์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ`์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก์ด๋‚˜ raw diff๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ'), true); + assert.equal(upgraded.includes('`์ „์ฒด์†Œ์Šค / raw diff`'), true); +}); + +test('upgradeLegacyWorklogContent upgrades screenshot guidance to require full screenshot', () => { + const legacyContent = `# 2026-04-07 ์ž‘์—…์ผ์ง€ + +## ์Šคํฌ๋ฆฐ์ƒท + +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท ์—†์Œ +`; + + const upgraded = upgradeLegacyWorklogContent(legacyContent); + assert.equal(upgraded.includes('- ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท 1์žฅ์€ ํ•„์ˆ˜'), true); + assert.equal(upgraded.includes('- ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ ํ•„์š”ํ•œ ๋งŒํผ ์ถ”๊ฐ€'), true); +}); + +test('upgradeLegacyWorklogContent removes obsolete auto-generated summary sections', () => { + const legacyContent = `# 2026-04-07 ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ์ž‘์—… ์ •๋ฆฌ + +## ์ปค๋ฐ‹ ๋ชฉ๋ก (2026-04-07, KST ๊ธฐ์ค€) + +- \`abc1234\` example + +## ๋ณ€๊ฒฝ ์š”์•ฝ + +- ์ž๋™ ์ˆ˜์ง‘ ์š”์•ฝ + +## ๋ณ€๊ฒฝ ํ†ต๊ณ„ (ํŒŒ์ผ๋ณ„, KST ๊ธฐ์ค€) + +- src/example.ts (+1/-1) + +## ๋ผ์ธ ํ†ต๊ณ„ (KST ๊ธฐ์ค€) + +- ์ถ”๊ฐ€: 1์ค„ + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ + +- M src/example.ts +`; + + const upgraded = upgradeLegacyWorklogContent(legacyContent); + assert.equal(upgraded.includes('## ์ปค๋ฐ‹ ๋ชฉ๋ก'), false); + assert.equal(upgraded.includes('## ๋ณ€๊ฒฝ ์š”์•ฝ'), false); + assert.equal(upgraded.includes('## ๋ณ€๊ฒฝ ํ†ต๊ณ„'), false); + assert.equal(upgraded.includes('## ๋ผ์ธ ํ†ต๊ณ„'), false); + assert.equal(upgraded.includes('## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ'), true); +}); diff --git a/etc/servers/work-server/src/services/worklog-automation-service.ts b/etc/servers/work-server/src/services/worklog-automation-service.ts new file mode 100755 index 0000000..edd4ded --- /dev/null +++ b/etc/servers/work-server/src/services/worklog-automation-service.ts @@ -0,0 +1,77 @@ +import { db } from '../db/client.js'; +export { + DEFAULT_DAILY_CREATE_TIME, + ensureDailyWorklogFile, + getKstNowParts, + isValidDailyCreateTime, + isWorklogCreationDue, + normalizeDailyCreateTime, + renderWorklogTemplate, +} from './worklog-automation-utils.js'; +import { DEFAULT_DAILY_CREATE_TIME, normalizeDailyCreateTime } from './worklog-automation-utils.js'; + +export const WORKLOG_AUTOMATION_RUN_TABLE = 'worklog_automation_runs'; + +async function ensureWorklogAutomationRunTable() { + const hasTable = await db.schema.hasTable(WORKLOG_AUTOMATION_RUN_TABLE); + + if (!hasTable) { + await db.schema.createTable(WORKLOG_AUTOMATION_RUN_TABLE, (table) => { + table.increments('id').primary(); + table.string('run_date', 10).notNullable().unique(); + table.string('scheduled_time', 5).notNullable(); + table.string('worklog_path').notNullable(); + table.timestamp('executed_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + }); + return; + } + + const requiredColumns: Array<[string, (table: any) => void]> = [ + ['run_date', (table) => table.string('run_date', 10).notNullable().unique()], + ['scheduled_time', (table) => table.string('scheduled_time', 5).notNullable().defaultTo(DEFAULT_DAILY_CREATE_TIME)], + ['worklog_path', (table) => table.string('worklog_path').notNullable().defaultTo('')], + ['executed_at', (table) => table.timestamp('executed_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredColumns) { + const hasColumn = await db.schema.hasColumn(WORKLOG_AUTOMATION_RUN_TABLE, columnName); + if (!hasColumn) { + await db.schema.alterTable(WORKLOG_AUTOMATION_RUN_TABLE, (table) => { + createColumn(table); + }); + } + } +} + +export async function hasWorklogAutomationRunForDate(runDate: string) { + await ensureWorklogAutomationRunTable(); + const row = await db(WORKLOG_AUTOMATION_RUN_TABLE).where({ run_date: runDate }).first(); + return Boolean(row); +} + +export async function markWorklogAutomationRun(args: { + runDate: string; + scheduledTime: string; + worklogPath: string; +}) { + await ensureWorklogAutomationRunTable(); + + const existing = await db(WORKLOG_AUTOMATION_RUN_TABLE).where({ run_date: args.runDate }).first(); + if (existing) { + await db(WORKLOG_AUTOMATION_RUN_TABLE) + .where({ run_date: args.runDate }) + .update({ + scheduled_time: args.scheduledTime, + worklog_path: args.worklogPath, + executed_at: db.fn.now(), + }); + return; + } + + await db(WORKLOG_AUTOMATION_RUN_TABLE).insert({ + run_date: args.runDate, + scheduled_time: args.scheduledTime, + worklog_path: args.worklogPath, + executed_at: db.fn.now(), + }); +} diff --git a/etc/servers/work-server/src/services/worklog-automation-utils.ts b/etc/servers/work-server/src/services/worklog-automation-utils.ts new file mode 100755 index 0000000..f892817 --- /dev/null +++ b/etc/servers/work-server/src/services/worklog-automation-utils.ts @@ -0,0 +1,232 @@ +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const KST_TIME_ZONE = 'Asia/Seoul'; +export const DEFAULT_DAILY_CREATE_TIME = '18:00'; +const DEFAULT_WORKLOG_TEMPLATE = `# YYYY-MM-DD ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- + +## ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ + +- + +## ๊ฒฐ์ • ์‚ฌํ•ญ + +- + +## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- +- ์ด ์„น์…˜์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก, ๊ฒฝ๋กœ ๋‚˜์—ด, raw diff๋ฅผ ์ง์ ‘ ํ’€์–ด์“ฐ์ง€ ๋ง๊ณ  ์ž‘์—… ํ๋ฆ„๊ณผ ํŒ๋‹จ๋งŒ ์ •๋ฆฌ +- ํŒŒ์ผ ๋ชฉ๋ก์€ ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ ์„น์…˜์—, raw diff๋Š” ์†Œ์Šค ์„น์…˜์—๋งŒ ๊ธฐ๋ก + +## ์Šคํฌ๋ฆฐ์ƒท + +- ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท 1์žฅ์€ ํ•„์ˆ˜ +- ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ ํ•„์š”ํ•œ ๋งŒํผ ์ถ”๊ฐ€ +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท์ด ์—†์œผ๋ฉด ์ž‘์—… ์ข…๋ฃŒ ์ „ ๋ฐ˜๋“œ์‹œ ์ฑ„์›€ + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: \`path/to/file.tsx\` + +- ๋ณ€๊ฒฝ ๋˜๋Š” ์‹ ๊ทœ ์ถ”๊ฐ€ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ +- \`์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ\`์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก์ด๋‚˜ raw diff๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ +- \`์†Œ์Šค\` ํƒญ์—์„œ Codex preview ์Šคํƒ€์ผ์˜ \`์ „์ฒด์†Œ์Šค / raw diff\` ์ „ํ™˜์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์—๋Š” ํŒŒ์ผ๋ณ„ raw diff ์œ„์ฃผ๋กœ ๋‚จ๊น€ + +\`\`\`diff +# ์ด ํŒŒ์ผ์˜ raw diff +- before ++ after +\`\`\` + +### ํŒŒ์ผ 2: \`path/to/another-file.ts\` + +- ํ•„์š” ์—†์œผ๋ฉด ์ด ์„น์…˜์€ ์‚ญ์ œ + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +\`\`\`bash +\`\`\` + +## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ + +- +`; + +const OBSOLETE_WORKLOG_SECTION_TITLES = new Set([ + '์ปค๋ฐ‹ ๋ชฉ๋ก', + '๋ณ€๊ฒฝ ์š”์•ฝ', + '๋ณ€๊ฒฝ ํ†ต๊ณ„', + '๋ผ์ธ ํ†ต๊ณ„', +]); + +type KstNowParts = { + dateKey: string; + minutesOfDay: number; +}; + +function extractKstParts(date: Date) { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: KST_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + + const values = Object.fromEntries(parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value])); + return { + year: Number(values.year ?? '0'), + month: Number(values.month ?? '0'), + day: Number(values.day ?? '0'), + hour: Number(values.hour ?? '0'), + minute: Number(values.minute ?? '0'), + }; +} + +export function isValidDailyCreateTime(value: string | undefined): value is string { + return typeof value === 'string' && /^\d{2}:\d{2}$/.test(value); +} + +export function normalizeDailyCreateTime(value: string | undefined) { + return isValidDailyCreateTime(value) ? value : DEFAULT_DAILY_CREATE_TIME; +} + +export function getKstNowParts(date = new Date()): KstNowParts { + const { year, month, day, hour, minute } = extractKstParts(date); + return { + dateKey: `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`, + minutesOfDay: hour * 60 + minute, + }; +} + +export function isWorklogCreationDue(now: Date, dailyCreateTime: string, alreadyExecutedToday: boolean) { + if (alreadyExecutedToday) { + return false; + } + + const [hours, minutes] = normalizeDailyCreateTime(dailyCreateTime).split(':').map((value) => Number(value)); + const nowParts = getKstNowParts(now); + return nowParts.minutesOfDay >= (hours * 60 + minutes); +} + +export function renderWorklogTemplate(template: string, dateKey: string) { + return template.replaceAll('YYYY-MM-DD', dateKey); +} + +function normalizeLevelTwoHeading(line: string) { + const match = line.match(/^##\s+([^\n]+)$/m); + + if (!match) { + return ''; + } + + return match[1]?.replace(/\s+\(.*\)\s*$/u, '').trim() ?? ''; +} + +function stripObsoleteWorklogSections(content: string) { + const sections = content.split(/\n(?=##\s+)/); + const preservedSections = sections.filter((section) => { + const normalizedHeading = normalizeLevelTwoHeading(section); + return !OBSOLETE_WORKLOG_SECTION_TITLES.has(normalizedHeading); + }); + + return `${preservedSections.join('\n\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`; +} + +const LEGACY_SOURCE_SECTION = `## ์†Œ์Šค + +- ์š”์•ฝ ์„ค๋ช… +- \`path/to/file.tsx\`: ๋ณ€๊ฒฝ ๋˜๋Š” ์‹ ๊ทœ ์ถ”๊ฐ€ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ + +\`\`\`diff +# ํ•ต์‹ฌ diff๋ฅผ 1~3๊ฐœ ๋ธ”๋ก์œผ๋กœ ๊ธฐ๋ก +- before ++ after +\`\`\``; + +const FILE_SCOPED_SOURCE_SECTION = `## ์†Œ์Šค + +### ํŒŒ์ผ 1: \`path/to/file.tsx\` + +- ๋ณ€๊ฒฝ ๋˜๋Š” ์‹ ๊ทœ ์ถ”๊ฐ€ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ +- \`์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ\`์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก์ด๋‚˜ raw diff๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ +- \`์†Œ์Šค\` ํƒญ์—์„œ Codex preview ์Šคํƒ€์ผ์˜ \`์ „์ฒด์†Œ์Šค / raw diff\` ์ „ํ™˜์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์—๋Š” ํŒŒ์ผ๋ณ„ raw diff ์œ„์ฃผ๋กœ ๋‚จ๊น€ + +\`\`\`diff +# ์ด ํŒŒ์ผ์˜ raw diff +- before ++ after +\`\`\` + +### ํŒŒ์ผ 2: \`path/to/another-file.ts\` + +- ํ•„์š” ์—†์œผ๋ฉด ์ด ์„น์…˜์€ ์‚ญ์ œ`; + +export function upgradeLegacyWorklogContent(content: string) { + let upgradedContent = content; + + if (upgradedContent.includes(LEGACY_SOURCE_SECTION)) { + upgradedContent = upgradedContent.replace(LEGACY_SOURCE_SECTION, FILE_SCOPED_SOURCE_SECTION); + } + + upgradedContent = upgradedContent + .replace( + '## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ\n\n- ', + `## ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ + +- +- ์ด ์„น์…˜์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก, ๊ฒฝ๋กœ ๋‚˜์—ด, raw diff๋ฅผ ์ง์ ‘ ํ’€์–ด์“ฐ์ง€ ๋ง๊ณ  ์ž‘์—… ํ๋ฆ„๊ณผ ํŒ๋‹จ๋งŒ ์ •๋ฆฌ +- ํŒŒ์ผ ๋ชฉ๋ก์€ \`## ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ\`, raw diff๋Š” \`## ์†Œ์Šค\`์—์„œ๋งŒ ๊ธฐ๋ก`, + ) + .replace( + '## ์Šคํฌ๋ฆฐ์ƒท\n\n- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท ์—†์Œ', + `## ์Šคํฌ๋ฆฐ์ƒท + +- ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท 1์žฅ์€ ํ•„์ˆ˜ +- ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์€ ํ•„์š”ํ•œ ๋งŒํผ ์ถ”๊ฐ€ +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท์ด ์—†์œผ๋ฉด ์ž‘์—… ์ข…๋ฃŒ ์ „ ๋ฐ˜๋“œ์‹œ ์ฑ„์›€`, + ) + .replaceAll('# ์ด ํŒŒ์ผ์˜ ํ•ต์‹ฌ diff', '# ์ด ํŒŒ์ผ์˜ raw diff') + .replaceAll('`์ž‘์—…์ผ์ง€` ํƒญ์—๋Š” ์ค‘๋ณต ํŒŒ์ผ ๋ชฉ๋ก์„ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์•„๋„ ๋จ', '`์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ`์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก์ด๋‚˜ raw diff๋ฅผ ๋‹ค์‹œ ์“ฐ์ง€ ์•Š์Œ') + .replaceAll('`์†Œ์Šค` ํƒญ์—์„œ ์ „์ฒด์†Œ์Šค/diff๋ฅผ ์ „ํ™˜ํ•ด ๋ณด๋ฏ€๋กœ ์—ฌ๊ธฐ์—๋Š” ํŒŒ์ผ๋ณ„ raw diff ์œ„์ฃผ๋กœ ๋‚จ๊น€', '`์†Œ์Šค` ํƒญ์—์„œ Codex preview ์Šคํƒ€์ผ์˜ `์ „์ฒด์†Œ์Šค / raw diff` ์ „ํ™˜์„ ์ œ๊ณตํ•˜๋ฏ€๋กœ ์—ฌ๊ธฐ์—๋Š” ํŒŒ์ผ๋ณ„ raw diff ์œ„์ฃผ๋กœ ๋‚จ๊น€'); + + return stripObsoleteWorklogSections(upgradedContent); +} + +async function readWorklogTemplate(repoPath: string) { + const templatePath = path.join(repoPath, 'docs', 'templates', 'worklog-template.md'); + + try { + return await readFile(templatePath, 'utf8'); + } catch { + return DEFAULT_WORKLOG_TEMPLATE; + } +} + +export async function ensureDailyWorklogFile(repoPath: string, dateKey: string) { + const worklogPath = path.join(repoPath, 'docs', 'worklogs', `${dateKey}.md`); + + try { + await access(worklogPath); + const existingContent = await readFile(worklogPath, 'utf8'); + const upgradedContent = upgradeLegacyWorklogContent(existingContent); + + if (upgradedContent !== existingContent) { + await writeFile(worklogPath, upgradedContent, 'utf8'); + } + + return worklogPath; + } catch { + const template = await readWorklogTemplate(repoPath); + await mkdir(path.dirname(worklogPath), { recursive: true }); + await writeFile(worklogPath, renderWorklogTemplate(template, dateKey), 'utf8'); + return worklogPath; + } +} diff --git a/etc/servers/work-server/src/types/web-push.d.ts b/etc/servers/work-server/src/types/web-push.d.ts new file mode 100755 index 0000000..b25c5f6 --- /dev/null +++ b/etc/servers/work-server/src/types/web-push.d.ts @@ -0,0 +1 @@ +declare module 'web-push'; diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts new file mode 100755 index 0000000..71b443d --- /dev/null +++ b/etc/servers/work-server/src/workers/plan-worker.ts @@ -0,0 +1,1363 @@ +import { spawn } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { access } from 'node:fs/promises'; +import type { FastifyBaseLogger } from 'fastify'; +import { getEnv } from '../config/env.js'; +import { getAppConfigSnapshot } from '../services/app-config-service.js'; +import { createErrorLog, ERROR_LOG_VIEW_TOKEN } from '../services/error-log-service.js'; +import { sendNotifications } from '../services/notification-service.js'; +import { buildPlanNotificationData, shouldSendPlanNotification } from '../services/plan-notification-service.js'; +import { + ensureDailyWorklogFile, + getKstNowParts, + hasWorklogAutomationRunForDate, + isWorklogCreationDue, + markWorklogAutomationRun, + normalizeDailyCreateTime, +} from '../services/worklog-automation-service.js'; +import { + claimNextPlanForBranch, + claimNextPlanForExecution, + claimNextPlanForMerge, + claimNextPlanForMainMerge, + formatPlanNotificationLabel, + isPlanLockedByWorker, + mapPlanRow, + markPlanAsCompleted, + markPlanAutomationFailure, + markPlanMainMergeFailure, + markPlanBranchReady, + markPlanReleaseMerged, + markPlanMerged, + markPlanWorkCompleted, + upsertAutoPlanItem, +} from '../services/plan-service.js'; +import { + cleanAutomationWorktree, + ensureBranchExists, + mergeBranchToRelease, + mergeReleaseToMain, + pullMainProjectBranch, + pushBranch, +} from '../services/git-service.js'; +import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js'; + +const STREAM_CAPTURE_LIMIT = 256 * 1024; +const FIRST_PROGRESS_NOTIFICATION_MS = 60_000; +const SECOND_PROGRESS_NOTIFICATION_MS = 120_000; +const REPEATED_PROGRESS_NOTIFICATION_MS = 180_000; +const STARTED_REQUEST_SUMMARY_LIMIT = 72; +const ERROR_SUMMARY_MAX_LENGTH = 500; +const ERROR_SUMMARY_LINE_PATTERN = + /(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i; +const ERROR_SUMMARY_NOISE_PATTERN = + /^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i; +const PLAN_CODEX_RUNNER_MAX_ATTEMPTS = 2; +const PLAN_CODEX_RUNNER_RETRY_DELAY_MS = 5000; +const PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN = + /failed to record rollout items|failed to queue rollout items|channel closed/i; +const PLAN_CODEX_HOME_PERMISSION_PATTERN = + /failed to record rollout items|failed to queue rollout items|channel closed|read-only file system|eacces|permission/i; +const WEEKLY_DAY_TO_INDEX = { + sun: 0, + mon: 1, + tue: 2, + wed: 3, + thu: 4, + fri: 5, + sat: 6, +} as const; + +type AutomationSnapshot = NonNullable>['automation']>; +type AutoReceiveScheduleEvaluation = { + due: boolean; + nextEligibleAt: Date | null; + scheduleLabel: string; +}; + +type WorklogAutomationSnapshot = NonNullable>['worklogAutomation']>; + +function isValidTimeValue(value: string | undefined): value is string { + return typeof value === 'string' && /^\d{2}:\d{2}$/.test(value); +} + +function withTime(baseDate: Date, timeValue: string) { + const [hours, minutes] = timeValue.split(':').map((part) => Number(part)); + const nextDate = new Date(baseDate); + nextDate.setHours(hours ?? 0, minutes ?? 0, 0, 0); + return nextDate; +} + +function getStartOfMinute(date: Date) { + const nextDate = new Date(date); + nextDate.setSeconds(0, 0); + return nextDate; +} + +function normalizeRepeatIntervalMinutes(value: number | undefined) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 60; + } + + return Math.min(1440, Math.max(1, Math.round(value))); +} + +function getNextWeeklyDate(now: Date, targetDay: keyof typeof WEEKLY_DAY_TO_INDEX, timeValue: string) { + const candidate = withTime(now, timeValue); + const currentDay = now.getDay(); + const targetDayIndex = WEEKLY_DAY_TO_INDEX[targetDay]; + let dayOffset = targetDayIndex - currentDay; + + if (dayOffset < 0 || (dayOffset === 0 && candidate.getTime() <= now.getTime())) { + dayOffset += 7; + } + + candidate.setDate(candidate.getDate() + dayOffset); + return candidate; +} + +function buildAutoWorklogPlanWorkId(dateKey: string) { + return `auto-worklog-${dateKey}`; +} + +function stripAnsi(text: string) { + return text.replace(/\u001B\[[0-9;]*m/g, ''); +} + +function normalizeErrorSummaryLine(line: string) { + return stripAnsi(line) + .replace(/^\[plan-progress\]\s*/u, '') + .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+ERROR\s+[^:]+:\s*/u, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function summarizeFailureOutput(output: string, fallback: string) { + const normalizedLines = output + .split('\n') + .map((line) => normalizeErrorSummaryLine(line)) + .filter(Boolean) + .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line)); + + const bestLine = + normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ?? + normalizedLines.at(-1) ?? + fallback.trim(); + + const summary = bestLine.replace(/\s+/g, ' ').trim(); + return summary.slice(0, ERROR_SUMMARY_MAX_LENGTH) || '์ž๋™ ์ž‘์—… ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; +} + +function isTransientPlanCodexFailure(error: unknown) { + const message = error instanceof Error ? error.message : String(error ?? ''); + return PLAN_CODEX_RUNNER_TRANSIENT_FAILURE_PATTERN.test(message); +} + +async function wait(ms: number) { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function canAccessPath(pathValue: string | undefined, mode: number) { + if (!pathValue) { + return false; + } + + try { + await access(pathValue, mode); + return true; + } catch { + return false; + } +} + +function appendCodexHomeHint(message: string, env: ReturnType) { + if (!PLAN_CODEX_HOME_PERMISSION_PATTERN.test(message)) { + return message; + } + + const codexHome = process.env.CODEX_HOME?.trim() || '(unset)'; + const templateHome = env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '(unset)'; + return `${message} [hint: CODEX_HOME=${codexHome}, template=${templateHome}, writable home/volume and permissions๋ฅผ ํ™•์ธํ•˜์„ธ์š”.]`; +} + +export class PlanWorker { + private readonly workerId: string; + private intervalMs: number; + private timer: NodeJS.Timeout | null = null; + private running = false; + private lastAutoReceiveAt: Date | null = null; + private lastWorklogAutomationRequestAt: Date | null = null; + + constructor(private readonly logger: FastifyBaseLogger) { + const env = getEnv(); + this.workerId = env.PLAN_WORKER_ID || `plan-worker-${process.pid}`; + this.intervalMs = env.PLAN_WORKER_INTERVAL_MS; + } + + private isLocalMainMode() { + return Boolean(getEnv().PLAN_LOCAL_MAIN_MODE); + } + + start() { + const env = getEnv(); + + if (!env.PLAN_WORKER_ENABLED) { + this.logger.info('Plan worker is disabled'); + return; + } + + if (this.timer) { + return; + } + + this.intervalMs = env.PLAN_WORKER_INTERVAL_MS; + void (async () => { + const codexHome = process.env.CODEX_HOME?.trim() || ''; + const codexTemplateHome = env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || ''; + const [codexHomeWritable, codexTemplateReadable] = await Promise.all([ + canAccessPath(codexHome, fsConstants.W_OK), + canAccessPath(codexTemplateHome, fsConstants.R_OK), + ]); + + this.logger.info( + { + workerId: this.workerId, + intervalMs: this.intervalMs, + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + codexHome: codexHome || null, + codexHomeWritable, + codexTemplateHome: codexTemplateHome || null, + codexTemplateReadable, + }, + 'Plan worker started', + ); + })(); + + void this.tick(); + this.timer = setInterval(() => { + void this.tick(); + }, this.intervalMs); + } + + async stop() { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + private async persistAutomationErrorLog(args: { + planId: number; + workId: string | null | undefined; + workerStatus: '๋ธŒ๋žœ์น˜์‹คํŒจ' | '์ž๋™์ž‘์—…์‹คํŒจ' | 'release๋ฐ˜์˜์‹คํŒจ' | 'main๋ฐ˜์˜์‹คํŒจ'; + error: unknown; + detail: Record; + }) { + const handledError = args.error instanceof Error ? args.error : new Error(String(args.error)); + + try { + await createErrorLog({ + source: 'automation', + sourceLabel: 'Plan ์ž๋™ํ™” ์›Œ์ปค', + errorType: args.workerStatus, + errorName: handledError.name, + errorMessage: handledError.message || '์ž๋™ํ™” ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', + detail: JSON.stringify(args.detail, null, 2), + stackTrace: handledError.stack ?? null, + relatedPlanId: args.planId, + relatedWorkId: args.workId ?? null, + context: args.detail, + }); + } catch (loggingError) { + this.logger.error({ err: loggingError, planId: args.planId }, 'Failed to persist automation error log'); + } + } + + private async notifyPlan(planId: number, workId: string, title: string, body: string, eventType: string) { + try { + if (!(await shouldSendPlanNotification(eventType))) { + this.logger.info({ planId, workId, eventType, skipped: true, reason: 'event-disabled' }, 'Notification send skipped'); + return; + } + + const result = await sendNotifications({ + title, + body, + threadId: `plan-${planId}`, + data: buildPlanNotificationData(planId, workId, eventType), + }); + + this.logger.info( + { + planId, + workId, + eventType, + ios: { + skipped: result.ios.skipped, + sentCount: result.ios.sentCount, + failedCount: result.ios.failedCount, + invalidTokens: result.ios.invalidTokens ?? [], + }, + web: { + skipped: result.web.skipped, + sentCount: result.web.sentCount, + failedCount: result.web.failedCount, + invalidEndpoints: result.web.invalidEndpoints, + }, + }, + 'Notification send completed', + ); + + if (!result.ios.skipped && result.ios.failedCount > 0) { + this.logger.warn({ planId, failedCount: result.ios.failedCount }, 'iOS notification send had failures'); + } + + if (!result.web.skipped && result.web.failedCount > 0) { + this.logger.warn({ planId, failedCount: result.web.failedCount }, 'Web push notification send had failures'); + } + } catch (error) { + this.logger.error({ planId, err: error }, 'Notification send failed'); + } + } + + private summarizeAutomationProgress(output: string) { + const rawLines = output + .split(/\r?\n/) + .map((line) => line.trim()); + + const sectionHeaders = [ + '์ž‘์—… ID', + '์š”์ฒญ ์š”์•ฝ', + '์ƒ์„ธ ์š”์ฒญ', + '์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ', + '์ตœ๊ทผ ์ด์Šˆ ์ด๋ ฅ', + '๊ทœ์น™', + 'Detailed Request', + 'Request Summary', + 'Recent Actions', + 'Recent Issues', + 'Rules', + ]; + const excludedSections = new Set(['์š”์ฒญ ์š”์•ฝ', '์ƒ์„ธ ์š”์ฒญ', 'Detailed Request', 'Request Summary']); + let currentSection = ''; + const lines = rawLines.filter((line) => { + if (!line) { + return false; + } + + const matchedHeader = sectionHeaders.find((header) => line === header || line.startsWith(`${header}:`)); + + if (matchedHeader) { + currentSection = matchedHeader; + return !excludedSections.has(matchedHeader); + } + + if (/^\d+\.\s/.test(line) && currentSection === '๊ทœ์น™') { + return false; + } + + return !excludedSections.has(currentSection); + }); + + const meaningfulLines = lines.filter((line) => { + if (line.length < 6) { + return false; + } + + if (/^(error|warn|info|debug|trace)\b/i.test(line)) { + return false; + } + + if (/^(tokens?|cost|duration|elapsed|reading prompt|thinking)\b/i.test(line)) { + return false; + } + + if (/^(์ž‘์—…\s*ID|์š”์ฒญ\s*์š”์•ฝ|์ƒ์„ธ\s*์š”์ฒญ|์ตœ๊ทผ\s*์กฐ์น˜\s*์ด๋ ฅ|์ตœ๊ทผ\s*์ด์Šˆ\s*์ด๋ ฅ|๊ทœ์น™)\s*:/u.test(line)) { + return false; + } + + if (/^(you are|detailed request|request summary|recent actions|recent issues|rules)\s*:/i.test(line)) { + return false; + } + + if (/^[|/\\-]+$/.test(line)) { + return false; + } + + return true; + }); + + const recentLines = meaningfulLines.slice(-40); + const explicitProgressLine = [...recentLines] + .reverse() + .find((line) => line.startsWith('[plan-progress]')); + + if (explicitProgressLine) { + return explicitProgressLine.replace(/^\[plan-progress\]\s*/u, '').trim().slice(0, 160); + } + + const recentCommand = [...recentLines] + .reverse() + .map((line) => this.extractAutomationCommandSummary(line)) + .find(Boolean); + const recentFile = [...recentLines] + .reverse() + .flatMap((line) => { + const matches = line.match(/(?:[A-Za-z0-9._-]+\/)+[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g) ?? []; + return matches.map((match) => match.replace(/^\.?\//, '')); + }) + .find((filePath) => !filePath.startsWith('node_modules/')); + + const recentActionLine = [...recentLines].reverse().find((line) => { + return /(์ˆ˜์ •|๋ณ€๊ฒฝ|ํŒจ์น˜|๋กœ์ง|๊ตฌํ˜„|์ถ”๊ฐ€|์ƒ์„ฑ|์ž‘์„ฑ|๋ถ„์„|ํƒ์ƒ‰|ํ™•์ธ|๊ฒ€์ฆ|ํ…Œ์ŠคํŠธ|edit|modify|update|patch|implement|create|add|write|analy|inspect|search|review|test|verif)/i.test( + line, + ); + }); + + const actionSummary = this.summarizeAutomationAction(recentActionLine ?? recentFile ?? ''); + + if (recentFile && actionSummary && recentCommand) { + return `${recentFile} ${actionSummary} (${recentCommand})`.slice(0, 160); + } + + if (recentFile && actionSummary) { + return `${recentFile} ${actionSummary}`.slice(0, 160); + } + + if (recentCommand && actionSummary) { + return `${actionSummary} (${recentCommand})`.slice(0, 160); + } + + if (recentFile) { + return recentCommand ? `${recentFile} ํ™•์ธ ์ค‘ (${recentCommand})`.slice(0, 160) : `${recentFile} ํ™•์ธ ์ค‘`; + } + + if (actionSummary) { + return recentCommand ? `${actionSummary} (${recentCommand})`.slice(0, 160) : actionSummary; + } + + if (recentCommand) { + return `์ปค๋งจ๋“œ ์‹คํ–‰ ์ค‘ (${recentCommand})`.slice(0, 160); + } + + const latestLine = [...meaningfulLines].reverse()[0]; + + if (!latestLine) { + return '๊ด€๋ จ ํŒŒ์ผ๊ณผ ์ตœ๊ทผ ๋ณ€๊ฒฝ ์ง€์ ์„ ํ™•์ธํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'; + } + + return latestLine.replace(/\s+/g, ' ').slice(0, 160); + } + + private extractAutomationCommandSummary(line: string) { + const text = String(line ?? '').trim(); + + if (!text) { + return ''; + } + + const rawCommand = + text.match(/^Command:\s+(.+)$/i)?.[1] ?? + text.match(/^(?:Running|Execute(?:d|ing)?|Executing)\s*:?\s+(.+)$/i)?.[1] ?? + text.match(/^\$\s+(.+)$/)?.[1] ?? + ''; + + if (!rawCommand) { + return ''; + } + + const normalized = rawCommand + .replace(/^\/bin\/(?:bash|sh|zsh)\s+-lc\s+/i, '') + .replace(/^(?:bash|sh|zsh)\s+-lc\s+/i, '') + .replace(/^['"]|['"]$/g, '') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) { + return ''; + } + + return normalized.length > 96 ? `${normalized.slice(0, 95).trim()}โ€ฆ` : normalized; + } + + private summarizeAutomationAction(line: string) { + const text = String(line ?? '').replace(/\s+/g, ' ').trim(); + + if (!text) { + return ''; + } + + if (this.extractAutomationCommandSummary(text)) { + if (/\b(git|npm|pnpm|yarn|node|npx|tsx)\b/i.test(text)) { + return '๋„๊ตฌ ์‹คํ–‰ ์ค‘'; + } + + return '์ปค๋งจ๋“œ ์‹คํ–‰ ์ค‘'; + } + + if (/(ํ…Œ์ŠคํŠธ|๊ฒ€์ฆ|ํ™•์ธ|test|verify|check)/i.test(text)) { + return '๊ฒ€์ฆ ์ค‘'; + } + + if (/(๋ถ„์„|ํƒ์ƒ‰|์กฐ์‚ฌ|์ฝ|review|inspect|search|analy)/i.test(text)) { + return '์ฝ”๋“œ ๋ถ„์„ ์ค‘'; + } + + if (/(์ˆ˜์ •|๋ณ€๊ฒฝ|ํŒจ์น˜|fix|edit|modify|update|patch)/i.test(text)) { + return '์ˆ˜์ • ์ค‘'; + } + + if (/(๋กœ์ง|๊ตฌํ˜„|์ถ”๊ฐ€|์ƒ์„ฑ|์ž‘์„ฑ|build|implement|create|add|write)/i.test(text)) { + return '๋กœ์ง ์ž‘์„ฑ ์ค‘'; + } + + return text.slice(0, 160); + } + + private buildProgressNotificationTitle(planId: number, planLabel: string, progressSummary: string) { + const workSummary = progressSummary || '์ž‘์—… ๋‚ด์šฉ ํ™•์ธ ์ค‘'; + const compactSummary = workSummary + .replace(/^ํ˜„์žฌ ๋‹จ๊ณ„:\s*/u, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 48); + + const normalizedPlanLabel = String(planLabel ?? '').trim(); + const hasCustomLabel = + normalizedPlanLabel && + normalizedPlanLabel !== `#${planId}` && + normalizedPlanLabel !== '์ž‘์—…ID' && + normalizedPlanLabel !== '[์ž‘์—…ID]' && + normalizedPlanLabel.toLowerCase() !== 'undefined' && + normalizedPlanLabel.toLowerCase() !== 'null'; + const labelPrefix = hasCustomLabel ? `#${planId} ${normalizedPlanLabel}` : `#${planId}`; + return `[${labelPrefix}] ${compactSummary || '์ž๋™ ์ž‘์—… ์ง„ํ–‰์ค‘'}`; + } + + private buildExecutionCompletedBody(autoDeployToMain: boolean) { + return autoDeployToMain + ? '์ž๋™ ์ž‘์—…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ดํ›„ release/main ๋ฐ˜์˜์ด ๊ณ„์† ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค.' + : '์ž๋™ ์ž‘์—…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ดํ›„ release ๋ฐ˜์˜์€ ๋ณ„๋„ ์š”์ฒญ์œผ๋กœ ์ง„ํ–‰๋ฉ๋‹ˆ๋‹ค.'; + } + + private summarizeExecutionRequest(note: unknown, limit = STARTED_REQUEST_SUMMARY_LIMIT) { + const text = String(note ?? '') + .replace(/\s+/g, ' ') + .trim(); + + if (!text) { + return ''; + } + + if (text.length <= limit) { + return text; + } + + return `${text.slice(0, Math.max(0, limit - 1)).trim()}โ€ฆ`; + } + + private buildExecutionStartedBody(note: unknown) { + const requestSummary = this.summarizeExecutionRequest(note); + return requestSummary ? `์ž๋™ ์ž‘์—…์„ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.\n์š”์ฒญ: ${requestSummary}` : '์ž๋™ ์ž‘์—…์„ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + private buildAutoWorklogPlanNote(dateKey: string, worklogAutomation: WorklogAutomationSnapshot) { + const worklogPath = `docs/worklogs/${dateKey}.md`; + const screenshotDir = `docs/assets/worklogs/${dateKey}/`; + const templateLabel = worklogAutomation.template === 'simple' ? '๊ฐ„๋‹จํ˜•' : '์ƒ์„ธํ˜•'; + + return [ + `${dateKey} ๊ธฐ์ค€ ์—…๋ฌด์ผ์ง€ ์ž๋™ํ™” Plan์ž…๋‹ˆ๋‹ค.`, + `${worklogPath} ํŒŒ์ผ์— ๊ธˆ์ผ ์ž‘์—…์ผ์ง€๋ฅผ ์ •๋ฆฌํ•ด ์ฃผ์„ธ์š”.`, + `์˜ค๋Š˜ ์ž‘์—…, ์ด์Šˆ ๋ฐ ํ•ด๊ฒฐ, ๊ฒฐ์ • ์‚ฌํ•ญ, ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ์„ ์ตœ์‹  ์ƒํƒœ๋กœ ๋ฐ˜์˜ํ•ด ์ฃผ์„ธ์š”.`, + `${worklogPath}์˜ ์ž‘์—…์ผ์ง€ ํƒญ ๋ณธ๋ฌธ๊ณผ ์ƒ์„ธ ์ž‘์—… ๋‚ด์—ญ์—๋Š” ํŒŒ์ผ ๋ชฉ๋ก, ๊ฒฝ๋กœ ๋‚˜์—ด, raw diff, ์ค‘๋ณต ์†Œ์Šค ์„ค๋ช…์„ ์“ฐ์ง€ ๋ง๊ณ  ์ž‘์—… ํ๋ฆ„๊ณผ ํŒ๋‹จ๋งŒ ์ •๋ฆฌํ•ด ์ฃผ์„ธ์š”.`, + worklogAutomation.includeScreenshots + ? `${screenshotDir} ๊ฒฝ๋กœ์— ์˜ค๋Š˜ ์ž‘์—… ๊ธฐ์ค€ ์ „์ฒด ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท 1์žฅ ์ด์ƒ์„ ๋ฐ˜๋“œ์‹œ ์ €์žฅํ•˜๊ณ , ์œ„์ ฏ/์ปดํฌ๋„ŒํŠธ ๋‹จ์œ„ ๋ถ€๋ถ„ ์Šคํฌ๋ฆฐ์ƒท์ด ์žˆ์œผ๋ฉด ํ•จ๊ป˜ ์—ฐ๊ฒฐํ•ด ์ฃผ์„ธ์š”.` + : `${worklogPath}์˜ ์Šคํฌ๋ฆฐ์ƒท ์„น์…˜์€ ํ˜„์žฌ ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•ด๋„ ๋ฉ๋‹ˆ๋‹ค.`, + worklogAutomation.includeChangedFiles + ? `${worklogPath}์˜ ์†Œ์Šค ํƒญ์—์„œ ์ด์ „ ์ž‘์—…๋“ค๋„ Codex preview ์Šคํƒ€์ผ์˜ ์ „์ฒด์†Œ์Šค/raw diff๋ฅผ ๋ณผ ์ˆ˜ ์žˆ๊ฒŒ ๋ณ€๊ฒฝ/์‹ ๊ทœ ํŒŒ์ผ ๋ชฉ๋ก๊ณผ ํŒŒ์ผ๋ณ„ raw diff ๊ธฐ์ค€ ์ฆ์ ์„ ๋‚จ๊ฒจ ์ฃผ์„ธ์š”.` + : `${worklogPath}์˜ ํŒŒ์ผ ๋ณ€๊ฒฝ ์ฆ์ ์€ ๊ผญ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•„๋„ ๋ฉ๋‹ˆ๋‹ค.`, + worklogAutomation.includeCommandLogs + ? `${worklogPath}์˜ ์‹คํ–‰ ์ปค๋งจ๋“œ ์„น์…˜์— ์˜ค๋Š˜ ์‚ฌ์šฉํ•œ ์ฃผ์š” ๋ช…๋ น์„ ์ •๋ฆฌํ•ด ์ฃผ์„ธ์š”.` + : `${worklogPath}์˜ ์‹คํ–‰ ์ปค๋งจ๋“œ ์„น์…˜์€ ๋น„์›Œ ๋‘์–ด๋„ ๋ฉ๋‹ˆ๋‹ค.`, + `์—…๋ฌด์ผ์ง€ ํ…œํ”Œ๋ฆฟ์€ ${templateLabel} ๊ธฐ์ค€์œผ๋กœ ์ •๋ฆฌํ•ด ์ฃผ์„ธ์š”.`, + ] + .filter(Boolean) + .join('\n'); + } + + private buildAutomationFinalStageTitle(planLabel: string) { + return `[${planLabel}] ์ž๋™ํ™” ๋งˆ์ง€๋ง‰ ๋‹จ๊ณ„`; + } + + private async notifyAutomationFinalStage( + planId: number, + workId: string, + planLabel: string, + body: string, + eventType: string, + ) { + await this.notifyPlan( + planId, + workId, + this.buildAutomationFinalStageTitle(planLabel), + body, + eventType, + ); + } + + private async runCodexCommandWithProgressNotifications( + planId: number, + workId: string, + note: unknown, + ) { + const env = getEnv(); + const runCodexCommandAttempt = async (attempt: number) => + await new Promise((resolve, reject) => { + let settled = false; + let stdout = ''; + let stderr = ''; + let notificationCount = 0; + let progressTimer: NodeJS.Timeout | null = null; + const planLabel = formatPlanNotificationLabel(workId, planId); + + const child = spawn('node', [env.PLAN_CODEX_RUNNER_PATH], { + cwd: env.PLAN_GIT_REPO_PATH, + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + PLAN_REPO_PATH: env.PLAN_LOCAL_MAIN_MODE ? env.PLAN_MAIN_PROJECT_REPO_PATH : env.PLAN_GIT_REPO_PATH, + PLAN_API_BASE_URL: 'http://127.0.0.1:3100/api', + PLAN_ACCESS_TOKEN: ERROR_LOG_VIEW_TOKEN, + PLAN_ITEM_ID: String(planId), + PLAN_CODEX_BIN: env.PLAN_CODEX_BIN, + PLAN_CODEX_TEMPLATE_HOME: env.PLAN_CODEX_TEMPLATE_HOME, + PLAN_GIT_USER_NAME: env.PLAN_GIT_USER_NAME, + PLAN_GIT_USER_EMAIL: env.PLAN_GIT_USER_EMAIL, + PLAN_LOCAL_MAIN_MODE: env.PLAN_LOCAL_MAIN_MODE ? 'true' : 'false', + PLAN_SKIP_WORK_COMPLETE: 'true', + }, + }); + + const clearProgressTimer = () => { + if (progressTimer) { + clearTimeout(progressTimer); + progressTimer = null; + } + }; + + const scheduleProgressNotification = (delayMs: number) => { + progressTimer = setTimeout(() => { + void (async () => { + const stillOwned = await isPlanLockedByWorker(planId, this.workerId); + + if (!stillOwned) { + clearProgressTimer(); + return; + } + + const mergedOutput = `${stdout}\n${stderr}`.trim(); + const progressSummary = this.summarizeAutomationProgress(mergedOutput); + const elapsedMinutes = + notificationCount === 0 ? 1 : notificationCount === 1 ? 3 : 3 + (notificationCount - 1) * 3; + notificationCount += 1; + + await this.notifyPlan( + planId, + workId, + this.buildProgressNotificationTitle(planId, planLabel, progressSummary), + `${elapsedMinutes}๋ถ„ ๊ฒฝ๊ณผํ–ˆ์Šต๋‹ˆ๋‹ค.\nํ˜„์žฌ ์ฒ˜๋ฆฌ: ${progressSummary}`, + 'work-progress', + ); + + const nextDelayMs = + notificationCount === 1 ? SECOND_PROGRESS_NOTIFICATION_MS : REPEATED_PROGRESS_NOTIFICATION_MS; + scheduleProgressNotification(nextDelayMs); + })(); + }, delayMs); + }; + + child.stdout?.on('data', (chunk) => { + stdout = (stdout + String(chunk)).slice(-STREAM_CAPTURE_LIMIT); + }); + + child.stderr?.on('data', (chunk) => { + stderr = (stderr + String(chunk)).slice(-STREAM_CAPTURE_LIMIT); + }); + + child.on('error', (error) => { + if (settled) { + return; + } + + settled = true; + clearProgressTimer(); + reject(error); + }); + + child.on('close', (code, signal) => { + if (settled) { + return; + } + + settled = true; + clearProgressTimer(); + + if (code === 0) { + resolve(stdout.trim()); + return; + } + + const details = summarizeFailureOutput( + `${stderr}\n${stdout}`, + `Codex exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`, + ); + reject(new Error(details)); + }); + + if (attempt > 1) { + this.logger.warn( + { + planId, + workId, + attempt, + maxAttempts: PLAN_CODEX_RUNNER_MAX_ATTEMPTS, + }, + 'Retrying plan codex runner after transient failure', + ); + } + + scheduleProgressNotification(FIRST_PROGRESS_NOTIFICATION_MS); + }); + + let lastError: unknown = null; + + for (let attempt = 1; attempt <= PLAN_CODEX_RUNNER_MAX_ATTEMPTS; attempt += 1) { + try { + return await runCodexCommandAttempt(attempt); + } catch (error) { + lastError = error; + + if (attempt >= PLAN_CODEX_RUNNER_MAX_ATTEMPTS || !isTransientPlanCodexFailure(error)) { + throw error; + } + + await this.notifyPlan( + planId, + workId, + `[${formatPlanNotificationLabel(workId, planId)}] ์ž๋™ํ™” ์žฌ์‹œ๋„`, + `Codex ๋‚ด๋ถ€ ์ฑ„๋„ ์˜ค๋ฅ˜๋กœ ์ž๋™ ์ž‘์—…์„ ๋‹ค์‹œ ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค. (${attempt + 1}/${PLAN_CODEX_RUNNER_MAX_ATTEMPTS})`, + 'work-progress', + ); + + await wait(PLAN_CODEX_RUNNER_RETRY_DELAY_MS); + } + } + + throw lastError instanceof Error ? lastError : new Error(String(lastError ?? '์ž๋™ ์ž‘์—… ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.')); + } + + private refreshInterval(env: ReturnType) { + if (this.intervalMs === env.PLAN_WORKER_INTERVAL_MS) { + return; + } + + this.intervalMs = env.PLAN_WORKER_INTERVAL_MS; + + if (this.timer) { + clearInterval(this.timer); + this.timer = setInterval(() => { + void this.tick(); + }, this.intervalMs); + } + } + + private evaluateAutoReceiveSchedule(automation: AutomationSnapshot | undefined, now: Date): AutoReceiveScheduleEvaluation { + const scheduleType = automation?.autoReceiveScheduleType ?? 'interval'; + + if (scheduleType === 'daily') { + const dailyTime = isValidTimeValue(automation?.autoReceiveDailyTime) ? automation.autoReceiveDailyTime : '09:00'; + const todayScheduledAt = withTime(now, dailyTime); + const nextEligibleAt = now.getTime() >= todayScheduledAt.getTime() + ? todayScheduledAt + : todayScheduledAt; + const alreadyProcessedToday = + this.lastAutoReceiveAt && + this.lastAutoReceiveAt.getFullYear() === now.getFullYear() && + this.lastAutoReceiveAt.getMonth() === now.getMonth() && + this.lastAutoReceiveAt.getDate() === now.getDate() && + this.lastAutoReceiveAt.getTime() >= todayScheduledAt.getTime(); + + return { + due: now.getTime() >= todayScheduledAt.getTime() && !alreadyProcessedToday, + nextEligibleAt: + now.getTime() >= todayScheduledAt.getTime() + ? withTime(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1), dailyTime) + : todayScheduledAt, + scheduleLabel: `daily:${dailyTime}`, + }; + } + + if (scheduleType === 'weekly') { + const weeklyDay = automation?.autoReceiveWeeklyDay ?? 'mon'; + const weeklyTime = isValidTimeValue(automation?.autoReceiveWeeklyTime) ? automation.autoReceiveWeeklyTime : '09:00'; + const currentScheduledAt = getNextWeeklyDate(new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), weeklyDay, weeklyTime); + const alreadyProcessedThisWeek = + this.lastAutoReceiveAt !== null && this.lastAutoReceiveAt.getTime() >= currentScheduledAt.getTime(); + + return { + due: now.getTime() >= currentScheduledAt.getTime() && !alreadyProcessedThisWeek, + nextEligibleAt: getNextWeeklyDate(now, weeklyDay, weeklyTime), + scheduleLabel: `weekly:${weeklyDay}:${weeklyTime}`, + }; + } + + const intervalSeconds = + typeof automation?.autoReceiveIntervalSeconds === 'number' && Number.isFinite(automation.autoReceiveIntervalSeconds) + ? Math.max(1, Math.round(automation.autoReceiveIntervalSeconds)) + : 30; + const lastAutoReceiveAt = this.lastAutoReceiveAt; + const nextEligibleAt = lastAutoReceiveAt + ? new Date(lastAutoReceiveAt.getTime() + intervalSeconds * 1000) + : getStartOfMinute(now); + + return { + due: !lastAutoReceiveAt || now.getTime() >= nextEligibleAt.getTime(), + nextEligibleAt, + scheduleLabel: `interval:${intervalSeconds}`, + }; + } + + private async tick() { + if (this.running) { + return; + } + + this.running = true; + + try { + const env = getEnv(); + const appConfig = await getAppConfigSnapshot(); + const autoRefreshEnabled = appConfig.automation?.autoRefreshEnabled ?? true; + const autoRefreshIntervalSeconds = appConfig.automation?.autoRefreshIntervalSeconds; + const resolvedIntervalMs = + typeof autoRefreshIntervalSeconds === 'number' && Number.isFinite(autoRefreshIntervalSeconds) + ? Math.max(1, Math.round(autoRefreshIntervalSeconds)) * 1000 + : env.PLAN_WORKER_INTERVAL_MS; + + if (!env.PLAN_WORKER_ENABLED || !autoRefreshEnabled) { + return; + } + + this.refreshInterval({ + ...env, + PLAN_WORKER_INTERVAL_MS: resolvedIntervalMs, + }); + + await this.processWorklogAutomation(appConfig); + await this.processPlanScheduledTasks(); + await this.processRegisteredPlans(); + await this.processExecutablePlans(appConfig); + await this.processReleaseReadyPlans(); + await this.processMainMergeReadyPlans(); + } finally { + this.running = false; + } + } + + private async processPlanScheduledTasks() { + try { + const registeredPlans = await registerDuePlanScheduledTasks(); + + if (registeredPlans.length > 0) { + this.logger.info({ count: registeredPlans.length }, 'Plan scheduled tasks registered'); + } + } catch (error) { + const handledError = error instanceof Error ? error : new Error(String(error)); + this.logger.error({ err: handledError }, 'Plan scheduled task processing failed'); + await createErrorLog({ + source: 'automation', + sourceLabel: 'Plan ์Šค์ผ€์ค„', + errorType: 'PlanScheduleFailed', + errorName: handledError.name, + errorMessage: handledError.message, + stackTrace: handledError.stack ?? null, + context: { + workerId: this.workerId, + }, + }); + } + } + + private async processRegisteredPlans() { + const env = getEnv(); + const row = await claimNextPlanForBranch(this.workerId); + + if (!row) { + return; + } + + const item = mapPlanRow(row); + const planId = Number(item.id); + const planLabel = formatPlanNotificationLabel(String(item.workId), planId); + const assignedBranch = String(item.assignedBranch ?? ''); + const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH); + + try { + if (!this.isLocalMainMode()) { + await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH); + await ensureBranchExists( + { + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + mainBranch: env.PLAN_MAIN_BRANCH, + }, + assignedBranch, + releaseTarget, + ); + } + + const updatedRow = await markPlanBranchReady(planId, this.workerId); + if (!updatedRow) { + this.logger.info({ planId }, 'Plan branch creation result ignored because ownership changed'); + return; + } + this.logger.info( + { planId, branch: assignedBranch, localMainMode: this.isLocalMainMode() }, + this.isLocalMainMode() ? 'Plan local main execution prepared' : 'Plan branch created', + ); + } catch (error) { + const message = error instanceof Error ? error.message : '๋ธŒ๋žœ์น˜ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + await this.persistAutomationErrorLog({ + planId, + workId: String(item.workId), + workerStatus: '๋ธŒ๋žœ์น˜์‹คํŒจ', + error, + detail: { + stage: 'branch', + workerId: this.workerId, + assignedBranch, + releaseTarget, + }, + }); + const failureRow = await markPlanAutomationFailure(planId, this.workerId, '๋ธŒ๋žœ์น˜์‹คํŒจ', message); + if (!failureRow) { + this.logger.info({ planId }, 'Plan branch failure ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + String(item.workId), + planLabel, + `๋ธŒ๋žœ์น˜ ์ƒ์„ฑ ์‹คํŒจ\n${message}`, + 'branch-failed', + ); + this.logger.error({ planId, err: error }, 'Plan branch creation failed'); + } + } + + private async processReleaseReadyPlans() { + const env = getEnv(); + const row = await claimNextPlanForMerge(this.workerId); + + if (!row) { + return; + } + + const item = mapPlanRow(row); + const planId = Number(item.id); + const planLabel = formatPlanNotificationLabel(String(item.workId), planId); + const assignedBranch = String(item.assignedBranch ?? ''); + const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH); + const autoDeployToMain = Boolean(item.autoDeployToMain ?? true); + + try { + if (this.isLocalMainMode()) { + const completedRow = await markPlanAsCompleted( + planId, + '๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ์—์„œ release/main ๋ฐ˜์˜ ๋‹จ๊ณ„๋ฅผ ๊ฑด๋„ˆ๋›ฐ๊ณ  ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + ); + if (!completedRow) { + this.logger.info({ planId }, 'Plan local main completion ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + String(item.workId), + planLabel, + '๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—…์ด ์ด๋ฏธ ๋ฐ˜์˜๋˜์–ด ๋ณ„๋„ release ๋ฐ˜์˜ ์—†์ด ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'work-local-main-complete', + ); + this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without release merge'); + return; + } + + await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH); + await mergeBranchToRelease( + { + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + mainBranch: env.PLAN_MAIN_BRANCH, + }, + assignedBranch, + releaseTarget, + ); + await pushBranch(env.PLAN_GIT_REPO_PATH, releaseTarget); + const updatedRow = await markPlanReleaseMerged(planId, this.workerId); + if (!updatedRow) { + this.logger.info({ planId }, 'Plan release merge result ignored because ownership changed'); + return; + } + if (!autoDeployToMain) { + await this.notifyAutomationFinalStage( + planId, + String(item.workId), + planLabel, + `${releaseTarget} ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, + 'release-merged', + ); + } + this.logger.info( + { planId, branch: assignedBranch, releaseTarget }, + 'Plan branch merged to release', + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'release ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + await this.persistAutomationErrorLog({ + planId, + workId: String(item.workId), + workerStatus: 'release๋ฐ˜์˜์‹คํŒจ', + error, + detail: { + stage: 'release-merge', + workerId: this.workerId, + assignedBranch, + releaseTarget, + autoDeployToMain, + }, + }); + const failureRow = await markPlanAutomationFailure(planId, this.workerId, 'release๋ฐ˜์˜์‹คํŒจ', message); + if (!failureRow) { + this.logger.info({ planId }, 'Plan release merge failure ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + String(item.workId), + planLabel, + `release ๋ฐ˜์˜ ์‹คํŒจ\n${message}`, + 'release-failed', + ); + this.logger.error({ planId, err: error }, 'Plan release merge failed'); + } + } + + private async processMainMergeReadyPlans() { + const env = getEnv(); + const row = await claimNextPlanForMainMerge(this.workerId); + + if (!row) { + return; + } + + const item = mapPlanRow(row); + const planId = Number(item.id); + const assignedBranch = String(item.assignedBranch ?? ''); + const releaseTarget = String(item.releaseTarget ?? env.PLAN_RELEASE_BRANCH); + const workId = String(item.workId); + const planLabel = formatPlanNotificationLabel(workId, planId); + + try { + if (this.isLocalMainMode()) { + const completedRow = await markPlanAsCompleted( + planId, + '๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ์—์„œ main ๋ฐ˜์˜ ๋‹จ๊ณ„๋ฅผ ๊ฑด๋„ˆ๋›ฐ๊ณ  ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'main๋ฐ˜์˜์™„๋ฃŒ', + ); + if (!completedRow) { + this.logger.info({ planId }, 'Plan local main main-merge completion ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + '๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—…์ด ์ด๋ฏธ ๋ฐ˜์˜๋˜์–ด ๋ณ„๋„ main merge ์—†์ด ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + 'work-local-main-complete', + ); + this.logger.info({ planId, branch: assignedBranch, releaseTarget }, 'Plan completed in local main mode without main merge'); + return; + } + + await cleanAutomationWorktree(env.PLAN_GIT_REPO_PATH); + await mergeReleaseToMain( + { + repoPath: env.PLAN_GIT_REPO_PATH, + releaseBranch: env.PLAN_RELEASE_BRANCH, + mainBranch: env.PLAN_MAIN_BRANCH, + }, + releaseTarget, + ); + await pushBranch(env.PLAN_GIT_REPO_PATH, env.PLAN_MAIN_BRANCH); + await pullMainProjectBranch(env.PLAN_MAIN_PROJECT_REPO_PATH, env.PLAN_MAIN_BRANCH); + const mergeResult = await markPlanMerged(planId, this.workerId); + if (!mergeResult?.mergedRow) { + this.logger.info({ planId }, 'Plan main merge result ignored because ownership changed'); + return; + } + const notificationRows = mergeResult.notificationRows.length > 0 ? mergeResult.notificationRows : [mergeResult.mergedRow]; + + for (const notificationRow of notificationRows) { + const notificationItem = mapPlanRow(notificationRow); + const notificationPlanId = Number(notificationItem.id); + const notificationWorkId = String(notificationItem.workId); + const notificationPlanLabel = formatPlanNotificationLabel(notificationWorkId, notificationPlanId); + + await this.notifyAutomationFinalStage( + notificationPlanId, + notificationWorkId, + notificationPlanLabel, + `${env.PLAN_MAIN_BRANCH} ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜ ํ›„ ๋ฉ”์ธ ํ”„๋กœ์ ํŠธ pull๊นŒ์ง€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.`, + 'main-merged', + ); + } + this.logger.info( + { + planId, + branch: assignedBranch, + releaseTarget, + mainBranch: env.PLAN_MAIN_BRANCH, + mainProjectRepoPath: env.PLAN_MAIN_PROJECT_REPO_PATH, + notifiedPlanIds: notificationRows.map((notificationRow) => Number(notificationRow.id)).filter(Number.isFinite), + }, + 'Plan release branch merged to main and main project synced', + ); + } catch (error) { + const message = error instanceof Error ? error.message : 'main ๋ธŒ๋žœ์น˜ ๋ฐ˜์˜์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + await this.persistAutomationErrorLog({ + planId, + workId, + workerStatus: 'main๋ฐ˜์˜์‹คํŒจ', + error, + detail: { + stage: 'main-merge', + workerId: this.workerId, + assignedBranch, + releaseTarget, + mainBranch: env.PLAN_MAIN_BRANCH, + mainProjectRepoPath: env.PLAN_MAIN_PROJECT_REPO_PATH, + }, + }); + const failureRows = await markPlanMainMergeFailure(releaseTarget, this.workerId, message); + if (!failureRows.length) { + this.logger.info({ planId }, 'Plan main merge failure ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + `main ์ผ๊ด„ ๋ฐ˜์˜ ์‹คํŒจ\n${message}`, + 'main-failed', + ); + this.logger.error({ planId, err: error }, 'Plan main merge failed'); + } + } + + private async processExecutablePlans(appConfig: Awaited> | undefined) { + const env = getEnv(); + if (!env.PLAN_CODEX_ENABLED) { + return; + } + + const schedule = this.evaluateAutoReceiveSchedule(appConfig?.automation, new Date()); + if (!schedule.due) { + return; + } + + const row = await claimNextPlanForExecution(this.workerId); + + if (!row) { + return; + } + + this.lastAutoReceiveAt = new Date(); + + const item = mapPlanRow(row); + const planId = Number(item.id); + const workId = String(item.workId); + const planLabel = formatPlanNotificationLabel(workId, planId); + const autoDeployToMain = Boolean(item.autoDeployToMain ?? true); + + try { + await this.notifyPlan( + planId, + workId, + `[${planLabel}] ์ž๋™ํ™” ์‹œ์ž‘`, + this.buildExecutionStartedBody(item.note), + 'work-started', + ); + + const output = await this.runCodexCommandWithProgressNotifications(planId, workId, item.note); + + if (output.includes('์ฒ˜๋ฆฌํ•  Plan ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.')) { + throw new Error('์ž๋™ ์ž‘์—… ๋Œ€์ƒ Plan ํ•ญ๋ชฉ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ƒํƒœ ์ „ํ™˜ ๋กœ์ง์„ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.'); + } + + if (output.includes('"outcome": "noop-complete"')) { + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + '๋ณ€๊ฒฝ ์‚ฌํ•ญ ์—†์ด ์ž๋™ ์ž‘์—…์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'work-noop-complete', + ); + return; + } + + if (output.includes('"outcome": "board-post-complete"')) { + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + 'Plan ๊ฒŒ์‹œํŒ ๋ฏธ์ ‘์ˆ˜ ๊ธ€ ์ž‘์„ฑ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + 'work-board-post-complete', + ); + return; + } + + const finalCompletedRow = this.isLocalMainMode() + ? await markPlanAsCompleted(planId, '๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—…์œผ๋กœ ์ž๋™ ์ž‘์—…์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.') + : await markPlanWorkCompleted(planId, this.workerId, '์ž๋™ ์ž‘์—…์„ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + if (!finalCompletedRow) { + this.logger.info({ planId }, 'Plan Codex completion ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + this.isLocalMainMode() ? '์ž๋™ ์ž‘์—…์ด ๋กœ์ปฌ main ์ž‘์—…๋ณธ์— ์ง์ ‘ ๋ฐ˜์˜๋˜์–ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.' : this.buildExecutionCompletedBody(autoDeployToMain), + 'work-completed', + ); + this.logger.info({ planId }, 'Plan Codex execution completed'); + } catch (error) { + const rawMessage = error instanceof Error ? error.message : '์ž๋™ ์ž‘์—… ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + const message = appendCodexHomeHint(rawMessage, env); + await this.persistAutomationErrorLog({ + planId, + workId, + workerStatus: '์ž๋™์ž‘์—…์‹คํŒจ', + error, + detail: { + stage: 'codex-execution', + workerId: this.workerId, + assignedBranch: item.assignedBranch ?? null, + autoDeployToMain, + releaseTarget: item.releaseTarget ?? env.PLAN_RELEASE_BRANCH, + }, + }); + const failureRow = await markPlanAutomationFailure(planId, this.workerId, '์ž๋™์ž‘์—…์‹คํŒจ', message); + if (!failureRow) { + this.logger.info({ planId }, 'Plan Codex failure ignored because ownership changed'); + return; + } + await this.notifyAutomationFinalStage( + planId, + workId, + planLabel, + `์ž๋™ ์ž‘์—… ์‹คํŒจ\n${message}`, + 'work-failed', + ); + this.logger.error({ planId, err: error }, 'Plan Codex execution failed'); + } + } + + private async processWorklogAutomation(appConfig: Awaited> | undefined) { + const env = getEnv(); + const worklogAutomation = appConfig?.worklogAutomation; + + if (!worklogAutomation?.autoCreateDailyWorklog) { + return; + } + + const now = new Date(); + const { dateKey } = getKstNowParts(now); + const dailyCreateTime = normalizeDailyCreateTime(worklogAutomation.dailyCreateTime); + const alreadyExecutedToday = await hasWorklogAutomationRunForDate(dateKey); + const repeatRequestEnabled = false; + const repeatIntervalMinutes = normalizeRepeatIntervalMinutes(worklogAutomation.repeatIntervalMinutes); + + if (!isWorklogCreationDue(now, dailyCreateTime, false)) { + return; + } + + if (alreadyExecutedToday && !repeatRequestEnabled) { + return; + } + + if (alreadyExecutedToday && this.lastWorklogAutomationRequestAt) { + const nextRepeatAt = new Date( + this.lastWorklogAutomationRequestAt.getTime() + repeatIntervalMinutes * 60 * 1000, + ); + + if (now.getTime() < nextRepeatAt.getTime()) { + return; + } + } + + try { + const worklogPath = await ensureDailyWorklogFile(env.PLAN_GIT_REPO_PATH, dateKey); + const autoWorklogPlanWorkId = buildAutoWorklogPlanWorkId(dateKey); + const upsertResult = await upsertAutoPlanItem({ + workId: autoWorklogPlanWorkId, + note: this.buildAutoWorklogPlanNote(dateKey, worklogAutomation), + releaseTarget: env.PLAN_RELEASE_BRANCH, + jangsingProcessingRequired: false, + autoDeployToMain: true, + requeue: !alreadyExecutedToday, + }); + this.lastWorklogAutomationRequestAt = now; + + if (upsertResult.action === 'created' || upsertResult.action === 'requeued') { + this.logger.info( + { dateKey, workId: autoWorklogPlanWorkId, worklogPath, repeatRequestEnabled, repeatIntervalMinutes }, + 'Daily worklog automation plan registered', + ); + } + + if (!alreadyExecutedToday) { + await markWorklogAutomationRun({ + runDate: dateKey, + scheduledTime: dailyCreateTime, + worklogPath, + }); + } + this.logger.info( + { dateKey, worklogPath, dailyCreateTime, repeatRequestEnabled, repeatIntervalMinutes }, + 'Daily worklog automation completed', + ); + } catch (error) { + const handledError = error instanceof Error ? error : new Error(String(error)); + this.logger.error({ err: handledError, dateKey, dailyCreateTime }, 'Daily worklog automation failed'); + await createErrorLog({ + source: 'automation', + sourceLabel: '์—…๋ฌด์ผ์ง€ ์ž๋™ํ™”', + errorType: 'WorklogAutomationFailed', + errorName: handledError.name, + errorMessage: handledError.message, + stackTrace: handledError.stack ?? null, + context: { + dateKey, + dailyCreateTime, + repeatRequestEnabled, + repeatIntervalMinutes, + repoPath: env.PLAN_GIT_REPO_PATH, + }, + }); + } + } +} diff --git a/etc/servers/work-server/tsconfig.json b/etc/servers/work-server/tsconfig.json new file mode 100755 index 0000000..c297cac --- /dev/null +++ b/etc/servers/work-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/index.html b/index.html new file mode 100755 index 0000000..b955610 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + AI Code App + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1f00128 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7475 @@ +{ + "name": "ai-code-app", + "version": "0.1.0-alpha.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-code-app", + "version": "0.1.0-alpha.0", + "dependencies": { + "@ant-design/icons": "^6.0.1", + "antd": "^5.27.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "playwright": "^1.59.0", + "typescript": "^5.9.2", + "vite": "^8.0.3", + "vite-plugin-pwa": "^1.2.0" + }, + "engines": { + "node": "22.22.2", + "npm": "10.9.7" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.1.tgz", + "integrity": "sha512-AMT4N2y++TZETNHiM77fs4a0uPVCJGuL5MTonk13Pvv7UN7sID1cNEZOc1qNqx6zLKAOilTEFAdAoAFKa0U//Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.330", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz", + "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz", + "integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz", + "integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-pwa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", + "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "workbox-build": "^7.4.0", + "workbox-window": "^7.4.0" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-build": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^2.4.1", + "@rollup/plugin-terser": "^0.4.3", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.79.2", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.0", + "workbox-broadcast-update": "7.4.0", + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-google-analytics": "7.4.0", + "workbox-navigation-preload": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-range-requests": "7.4.0", + "workbox-recipes": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0", + "workbox-streams": "7.4.0", + "workbox-sw": "7.4.0", + "workbox-window": "7.4.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.0", + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..c5d5049 --- /dev/null +++ b/package.json @@ -0,0 +1,69 @@ +{ + "name": "ai-code-app", + "private": false, + "version": "0.1.0-alpha.0", + "type": "module", + "packageManager": "npm@10.9.7", + "engines": { + "node": "22.22.2", + "npm": "10.9.7" + }, + "main": "./dist/package/index.js", + "module": "./dist/package/index.js", + "types": "./dist/package/index.d.ts", + "files": [ + "dist/package", + "src/styles.css", + "README.md" + ], + "exports": { + ".": { + "types": "./dist/package/index.d.ts", + "import": "./dist/package/index.js" + }, + "./styles.css": "./src/styles.css" + }, + "sideEffects": [ + "**/*.css" + ], + "publishConfig": { + "registry": "https://repo.sm-home.cloud/repository/npm-hosted/" + }, + "scripts": { + "dev": "vite", + "capture:component": "node scripts/capture-component-screenshot.mjs", + "capture:menu": "node scripts/capture-menu-screenshot.mjs", + "capture:feature": "node scripts/capture-feature-screenshot.mjs", + "capture:settings": "node scripts/capture-settings-screenshot.mjs", + "capture:fullscreen": "node scripts/capture-fullscreen-toggle-screenshot.mjs", + "capture:plan-mobile": "node scripts/capture-plan-board-mobile-screenshot.mjs", + "plan:codex:once": "node scripts/run-plan-codex-once.mjs", + "server-command:runner": "node scripts/run-server-command-runner.mjs", + "build:app": "tsc -b && vite build --outDir app-dist", + "build:test-app": "VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", + "build:lib": "tsc -p tsconfig.lib.json", + "build": "npm run build:lib && npm run build:app", + "prepublishOnly": "npm run build:lib", + "preview": "vite preview", + "preview:app": "node scripts/serve-app-dist.mjs", + "preview:test-app": "APP_DIST_DIR=/tmp/ai-code-test-app-dist node scripts/serve-app-dist.mjs" + }, + "dependencies": { + "@ant-design/icons": "^6.0.1", + "antd": "^5.27.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-router-dom": "^7.14.0", + "recharts": "^3.8.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "playwright": "^1.59.0", + "typescript": "^5.9.2", + "vite": "^8.0.3", + "vite-plugin-pwa": "^1.2.0" + } +} diff --git a/public/apple-touch-icon.svg b/public/apple-touch-icon.svg new file mode 100755 index 0000000..b7e1aa8 --- /dev/null +++ b/public/apple-touch-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100755 index 0000000..525c588 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/public/pwa-192x192.svg b/public/pwa-192x192.svg new file mode 100755 index 0000000..de62547 --- /dev/null +++ b/public/pwa-192x192.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/public/pwa-512x512.svg b/public/pwa-512x512.svg new file mode 100755 index 0000000..53ea973 --- /dev/null +++ b/public/pwa-512x512.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/scripts/capture-component-screenshot.mjs b/scripts/capture-component-screenshot.mjs new file mode 100755 index 0000000..188a33a --- /dev/null +++ b/scripts/capture-component-screenshot.mjs @@ -0,0 +1,63 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const cwd = process.cwd(); +const componentId = process.argv[2]; +const captureDate = process.argv[3] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; + +if (!componentId) { + console.error('Usage: node scripts/capture-component-screenshot.mjs [YYYY-MM-DD]'); + process.exit(1); +} + +const screenshotFileName = `${componentId}.png`; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName, +}); +const targetSelector = `#component-sample-${componentId}`; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ + viewport: { + width: 1600, + height: 1200, + }, + deviceScaleFactor: 2, +}); + +try { + await ensureDirectory(screenshotDir); + + const targetUrl = new URL(baseUrl); + targetUrl.searchParams.set('topMenu', 'apis'); + + await page.goto(targetUrl.toString(), { + waitUntil: 'domcontentloaded', + }); + + await page.waitForLoadState('networkidle').catch(() => {}); + + const target = page.locator(targetSelector).first(); + await target.waitFor({ state: 'visible', timeout: 30000 }); + await target.scrollIntoViewIfNeeded(); + await target.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: componentId, + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await browser.close(); +} diff --git a/scripts/capture-feature-screenshot.mjs b/scripts/capture-feature-screenshot.mjs new file mode 100755 index 0000000..96b335a --- /dev/null +++ b/scripts/capture-feature-screenshot.mjs @@ -0,0 +1,117 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const FEATURE_CAPTURE_PRESETS = { + 'docs-worklogs': { + topMenu: 'docs', + screenshotFileName: 'feature-docs-worklogs.png', + targetSelector: '.app-main-card', + }, + 'play-layout': { + topMenu: 'play', + screenshotFileName: 'feature-play-layout.png', + targetSelector: '.app-main-card', + query: { playSection: 'layout' }, + }, + 'apis-components': { + topMenu: 'apis', + screenshotFileName: 'feature-apis-components.png', + targetSelector: '.app-main-card', + }, + 'apis-widgets': { + topMenu: 'apis', + screenshotFileName: 'feature-apis-widgets.png', + targetSelector: '.app-main-card', + afterNavigation: async (page) => { + await page.getByRole('menuitem', { name: 'Widgets' }).click(); + }, + }, + 'plans-board': { + topMenu: 'plans', + screenshotFileName: 'feature-plans-board.png', + targetSelector: '.app-main-card', + }, + 'plans-charts': { + topMenu: 'plans', + screenshotFileName: 'feature-plans-charts.png', + targetSelector: '.app-main-card', + query: { planSection: 'charts' }, + }, + 'chat-live': { + topMenu: 'chat', + screenshotFileName: 'feature-chat-live.png', + targetSelector: '.app-main-card, .app-chat-panel', + }, + 'chat-errors': { + topMenu: 'chat', + screenshotFileName: 'feature-chat-errors.png', + targetSelector: '.app-main-card, .app-chat-panel', + afterNavigation: async (page) => { + await page.getByRole('menuitem', { name: '์—๋Ÿฌ ๋กœ๊ทธ' }).click(); + }, + }, +}; + +const cwd = process.cwd(); +const presetKey = process.argv[2]; +const captureDate = process.argv[3] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; + +if (!presetKey || !(presetKey in FEATURE_CAPTURE_PRESETS)) { + console.error(`Usage: node scripts/capture-feature-screenshot.mjs <${Object.keys(FEATURE_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`); + process.exit(1); +} + +const preset = FEATURE_CAPTURE_PRESETS[presetKey]; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName: preset.screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ + viewport: { width: 1600, height: 1200 }, + deviceScaleFactor: 2, +}); + +try { + await ensureDirectory(screenshotDir); + + const targetUrl = new URL(baseUrl); + targetUrl.searchParams.set('topMenu', preset.topMenu); + + if (preset.query) { + for (const [key, value] of Object.entries(preset.query)) { + targetUrl.searchParams.set(key, value); + } + } + + await page.goto(targetUrl.toString(), { waitUntil: 'networkidle' }); + + if (preset.afterNavigation) { + await preset.afterNavigation(page); + await page.waitForLoadState('networkidle').catch(() => {}); + } + + const target = page.locator(preset.targetSelector).first(); + await target.waitFor({ state: 'visible', timeout: 30000 }); + await target.scrollIntoViewIfNeeded(); + await target.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: preset.screenshotFileName.replace('.png', ''), + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await browser.close(); +} diff --git a/scripts/capture-fullscreen-toggle-screenshot.mjs b/scripts/capture-fullscreen-toggle-screenshot.mjs new file mode 100755 index 0000000..6fdd5d2 --- /dev/null +++ b/scripts/capture-fullscreen-toggle-screenshot.mjs @@ -0,0 +1,45 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const cwd = process.cwd(); +const captureDate = process.argv[2] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; +const screenshotFileName = 'main-content-fullscreen-toggle.png'; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ + viewport: { width: 1600, height: 1200 }, + deviceScaleFactor: 2, +}); + +try { + await ensureDirectory(screenshotDir); + + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + await page.getByLabel('์ฝ˜ํ…์ธ  ์ตœ๋Œ€ํ™”').click(); + + const target = page.locator('.app-main-content--expanded').first(); + await target.waitFor({ state: 'visible', timeout: 30000 }); + await target.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: 'main-content-fullscreen-toggle', + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await browser.close(); +} diff --git a/scripts/capture-menu-screenshot.mjs b/scripts/capture-menu-screenshot.mjs new file mode 100755 index 0000000..b7b520b --- /dev/null +++ b/scripts/capture-menu-screenshot.mjs @@ -0,0 +1,61 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const cwd = process.cwd(); +const menuGroup = process.argv[2] ?? 'docs'; +const captureDate = process.argv[3] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; + +const supportedMenuGroups = new Set(['docs', 'plans']); + +if (!supportedMenuGroups.has(menuGroup)) { + console.error('Usage: node scripts/capture-menu-screenshot.mjs [docs|plans] [YYYY-MM-DD]'); + process.exit(1); +} + +const screenshotFileName = `${menuGroup}-menu.png`; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ + viewport: { width: 1600, height: 1200 }, + deviceScaleFactor: 2, +}); + +try { + await ensureDirectory(screenshotDir); + + const targetUrl = new URL(baseUrl); + targetUrl.searchParams.set('topMenu', menuGroup === 'docs' ? 'docs' : 'plans'); + + await page.goto(targetUrl.toString(), { waitUntil: 'networkidle' }); + + if (menuGroup === 'plans') { + await page.getByLabel('์„ค์ •').click(); + await page.locator('.app-header__settings-menu').first().waitFor({ state: 'visible', timeout: 30000 }); + } + + const target = page.locator('.app-shell').first(); + await target.waitFor({ state: 'visible', timeout: 30000 }); + await target.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: screenshotFileName.replace('.png', ''), + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await browser.close(); +} diff --git a/scripts/capture-plan-board-mobile-screenshot.mjs b/scripts/capture-plan-board-mobile-screenshot.mjs new file mode 100755 index 0000000..f5f2979 --- /dev/null +++ b/scripts/capture-plan-board-mobile-screenshot.mjs @@ -0,0 +1,50 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const cwd = process.cwd(); +const captureDate = process.argv[2] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; +const screenshotFileName = 'plan-board-mobile-memo-detail.png'; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 430, height: 932 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 3, +}); +const page = await context.newPage(); + +try { + await ensureDirectory(screenshotDir); + + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + await page.getByText('Plans').click(); + await page.getByRole('button', { name: '์ƒˆ ๋ฉ”๋ชจ' }).click(); + + const overlayCard = page.locator('.plan-board-page__overlay-card').first(); + await overlayCard.waitFor({ state: 'visible', timeout: 30000 }); + await overlayCard.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: 'plan-board-mobile-memo-detail', + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await context.close(); + await browser.close(); +} diff --git a/scripts/capture-search-command-screenshot.mjs b/scripts/capture-search-command-screenshot.mjs new file mode 100755 index 0000000..08171fe --- /dev/null +++ b/scripts/capture-search-command-screenshot.mjs @@ -0,0 +1,98 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const cwd = process.cwd(); +const captureDate = process.argv[2] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:5174'; +const screenshotFileName = 'search-command.png'; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 430, height: 932 }, + isMobile: true, + hasTouch: true, + deviceScaleFactor: 3, +}); +const page = await context.newPage(); + +try { + await ensureDirectory(screenshotDir); + + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + + await page.evaluate(() => { + const sensor = Array.from(document.querySelectorAll('div')).find((element) => { + const style = window.getComputedStyle(element); + return ( + style.position === 'fixed' && + style.top === '0px' && + style.right === '0px' && + style.touchAction === 'none' && + element.childElementCount === 0 + ); + }); + + if (!sensor) { + throw new Error('Search gesture sensor not found.'); + } + + const rect = sensor.getBoundingClientRect(); + const startX = rect.right - 24; + const startY = rect.top + 18; + const endY = startY + 120; + + const createTouch = (clientY) => + new Touch({ + identifier: 1, + target: sensor, + clientX: startX, + clientY, + radiusX: 12, + radiusY: 12, + rotationAngle: 0, + force: 1, + }); + + const dispatch = (type, touches) => { + sensor.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches, + targetTouches: touches, + changedTouches: touches, + }), + ); + }; + + dispatch('touchstart', [createTouch(startY)]); + dispatch('touchmove', [createTouch(endY)]); + dispatch('touchend', []); + }); + + const modal = page.locator('.search-command-modal .ant-modal-content').first(); + await modal.waitFor({ state: 'visible', timeout: 30000 }); + await modal.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: 'search-command', + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await context.close(); + await browser.close(); +} diff --git a/scripts/capture-settings-screenshot.mjs b/scripts/capture-settings-screenshot.mjs new file mode 100755 index 0000000..5b0efc7 --- /dev/null +++ b/scripts/capture-settings-screenshot.mjs @@ -0,0 +1,83 @@ +import process from 'node:process'; +import { chromium } from 'playwright'; +import { ensureDirectory, getKstDate, resolveCapturePaths, updateWorklogCaptureSection } from './worklog-capture-utils.mjs'; + +const ACCESS_TOKEN = 'usr_7f3a9c2d8e1b4a6f'; +const TOKEN_ACCESS_STORAGE_KEY = 'work-app.token-access.registered-token'; + +const SETTINGS_CAPTURE_PRESETS = { + automation: { + screenshotFileName: 'settings-app.png', + triggerLabel: '์•ฑ ์„ค์ •', + }, + notification: { + screenshotFileName: 'settings-notification.png', + triggerLabel: '์•Œ๋ฆผ', + }, +}; + +const cwd = process.cwd(); +const presetKey = process.argv[2]; +const captureDate = process.argv[3] ?? getKstDate(); +const baseUrl = process.env.CAPTURE_BASE_URL ?? 'http://127.0.0.1:4173'; + +if (!presetKey || !(presetKey in SETTINGS_CAPTURE_PRESETS)) { + console.error(`Usage: node scripts/capture-settings-screenshot.mjs <${Object.keys(SETTINGS_CAPTURE_PRESETS).join('|')}> [YYYY-MM-DD]`); + process.exit(1); +} + +const preset = SETTINGS_CAPTURE_PRESETS[presetKey]; +const { screenshotDir, screenshotPath, worklogPath, markdownImagePath } = resolveCapturePaths({ + cwd, + captureDate, + screenshotFileName: preset.screenshotFileName, +}); + +const browser = await chromium.launch({ headless: true }); +const context = await browser.newContext({ + viewport: { width: 1600, height: 1200 }, + deviceScaleFactor: 2, +}); + +await context.addInitScript( + ({ tokenAccessStorageKey, accessToken }) => { + window.localStorage.setItem(tokenAccessStorageKey, accessToken); + }, + { + tokenAccessStorageKey: TOKEN_ACCESS_STORAGE_KEY, + accessToken: ACCESS_TOKEN, + }, +); + +const page = await context.newPage(); + +try { + await ensureDirectory(screenshotDir); + + const targetUrl = new URL(baseUrl); + targetUrl.searchParams.set('topMenu', 'plans'); + + await page.goto(targetUrl.toString(), { waitUntil: 'networkidle' }); + await page.getByLabel('์„ค์ •').click(); + await page.getByRole('button', { name: preset.triggerLabel }).click(); + + const modal = page.locator('.ant-modal-root .ant-modal-content').last(); + await modal.waitFor({ state: 'visible', timeout: 30000 }); + await modal.screenshot({ + path: screenshotPath, + animations: 'disabled', + }); + + await updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt: preset.screenshotFileName.replace('.png', ''), + markdownImagePath, + }); + + console.log(`Saved: ${screenshotPath}`); + console.log(`Linked in: ${worklogPath}`); +} finally { + await context.close(); + await browser.close(); +} diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs new file mode 100755 index 0000000..c494981 --- /dev/null +++ b/scripts/run-plan-codex-once.mjs @@ -0,0 +1,1716 @@ +import { createHash } from 'node:crypto'; +import { execFile, spawn } from 'node:child_process'; +import { promisify } from 'node:util'; +import { access, cp, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const execFileAsync = promisify(execFile); +const EXEC_MAX_BUFFER = 20 * 1024 * 1024; + +const repoPath = process.env.PLAN_REPO_PATH ?? process.cwd(); +const apiBaseUrl = process.env.PLAN_API_BASE_URL ?? 'http://127.0.0.1:3100/api'; +const accessToken = process.env.PLAN_ACCESS_TOKEN?.trim() ?? ''; +const planItemId = process.env.PLAN_ITEM_ID ? Number(process.env.PLAN_ITEM_ID) : null; +const codexBin = process.env.PLAN_CODEX_BIN ?? 'codex'; +const localMainMode = process.env.PLAN_LOCAL_MAIN_MODE === 'true'; +const skipWorkComplete = process.env.PLAN_SKIP_WORK_COMPLETE === 'true'; +const gitUserName = process.env.PLAN_GIT_USER_NAME ?? 'how2ice'; +const gitUserEmail = process.env.PLAN_GIT_USER_EMAIL ?? 'how2ice@naver.com'; +const previewBaseUrl = process.env.PLAN_PREVIEW_BASE_URL?.trim() || ''; +const previewUrlTemplate = process.env.PLAN_PREVIEW_URL_TEMPLATE?.trim() || ''; +const STREAM_CAPTURE_LIMIT = 256 * 1024; +const PROMPT_REQUEST_SUMMARY_LIMIT = 72; +const SOURCE_SNAPSHOT_MAX_BYTES = 200 * 1024; +const ERROR_SUMMARY_MAX_LENGTH = 500; +const SOURCE_SNAPSHOT_TEXT_PATTERN = + /\.(txt|log|csv|md|mdx|json|jsonl|ya?ml|xml|diff|patch|sh|bash|zsh|ini|cfg|conf|sql|js|jsx|ts|tsx|css|scss|less|html?|java|kt|py|rb|go|rs|svg)$/i; + +const ERROR_SUMMARY_LINE_PATTERN = + /(ERROR:|failed|failure|capacity|Read-only file system|permission|not permitted|sandbox|os error|ENOENT|EACCES|ECONN|SyntaxError|TypeError|ReferenceError)/i; +const ERROR_SUMMARY_NOISE_PATTERN = + /^(exec|codex|tokens used|succeeded in \d+ms:?|[><=]{3,}|[A-Za-z]:\\|\/bin\/bash\b|\d+\s*[:|])/i; +const CODEX_EXEC_MAX_ATTEMPTS = 2; +const CODEX_EXEC_RETRY_DELAY_MS = 3000; +const MAX_ERROR_LOG_PLAN_REGISTRATIONS = 6; +const CODEX_EXEC_TRANSIENT_FAILURE_PATTERN = + /failed to record rollout items|failed to queue rollout items|channel closed/i; +const CODEX_HOME_PERMISSION_PATTERN = + /failed to record rollout items|failed to queue rollout items|channel closed|read-only file system|eacces|permission/i; +const CODEX_HOME_RUNTIME_PATHS = [ + 'auth.json', + 'config.toml', + 'rules', + 'skills', + 'vendor_imports', + 'models_cache.json', + 'version.json', +]; + +function parseCodexTokenUsage(output) { + const text = stripAnsi(String(output ?? '')); + + if (!text) { + return null; + } + + const lines = text.split('\n').map((line) => line.trim()); + const lastMatchedIndex = lines.reduce((foundIndex, line, index) => { + return /tokens?\s+used/i.test(line) ? index : foundIndex; + }, -1); + + if (lastMatchedIndex < 0) { + return null; + } + + const matchedLine = lines[lastMatchedIndex] ?? ''; + const usageLines = [matchedLine]; + + if (!/\d/.test(matchedLine)) { + for (let index = lastMatchedIndex + 1; index < lines.length && usageLines.length < 5; index += 1) { + const nextLine = lines[index]?.trim() ?? ''; + + if (!nextLine) { + if (usageLines.length > 1) { + break; + } + continue; + } + + if (/^(?:\[plan-progress\]|done:|noop:|board_post:|recovered_commit:|git\s|diff --git|commit\s[0-9a-f]{7,})/i.test(nextLine)) { + break; + } + + if (!/\d/.test(nextLine) && !/(input|output|total|cached|reasoning)/i.test(nextLine)) { + if (usageLines.length > 1) { + break; + } + continue; + } + + usageLines.push(nextLine); + } + } + + return usageLines + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function reportProgress(message) { + const text = String(message ?? '').replace(/\s+/g, ' ').trim(); + + if (!text) { + return; + } + + process.stderr.write(`[plan-progress] ${text}\n`); +} + +function requiresSourceChange(note) { + const text = String(note ?? ''); + return /(?:\/|\\).+\.[a-z0-9]+/i.test(text) || /(์ƒ์„ฑ|๋งŒ๋“ค์–ด|์ถ”๊ฐ€|์ˆ˜์ •|๋ณ€๊ฒฝ|์‚ญ์ œ|ํŒŒ์ผ)/.test(text); +} + +function extractRequestedPaths(note) { + const text = String(note ?? ''); + const matches = text.match(/(?:[A-Za-z0-9._-]+[\/\\])+[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+/g) ?? []; + return [...new Set(matches.map((item) => item.replace(/\\/g, '/')))]; +} + +function isAutomationEnvironmentFailure(text) { + const message = String(text ?? ''); + return /bwrap|namespace ์˜ค๋ฅ˜|์ƒŒ๋“œ๋ฐ•์Šค ์ œ์•ฝ|ํŒŒ์ผ ์“ฐ๊ธฐ ์‹คํŒจ|sandbox/i.test(message); +} + +function isBoardDraftOnlyRequest(note) { + const text = String(note ?? '').replace(/\s+/g, ' ').trim(); + + if (!text) { + return false; + } + + const mentionsBoard = /(?:plan|ํ”Œ๋žœ)?\s*๊ฒŒ์‹œํŒ|๊ฒŒ์‹œ๊ธ€|board/i.test(text); + const asksToWrite = /์ž‘์„ฑ๋งŒ|๋“ฑ๋ก๋งŒ|๊ธ€\s*์ž‘์„ฑ|๊ฒŒ์‹œ๊ธ€\s*์ž‘์„ฑ|์ž‘์„ฑํ•ด|์ž‘์„ฑํ•ด์ค˜|์˜ฌ๋ ค์ค˜|๋‚จ๊ฒจ์ค˜/.test(text); + const keepsUnreceived = /๋ฏธ์ ‘์ˆ˜|์ ‘์ˆ˜\s*ํ•˜์ง€|์ ‘์ˆ˜ํ•˜์ง€|์ž๋™ํ™”\s*์ ‘์ˆ˜\s*(?:ํ•˜์ง€|๊ธˆ์ง€|์•ˆ)/.test(text); + + return mentionsBoard && asksToWrite && keepsUnreceived; +} + +function normalizeAutomationType(value) { + return value === 'plan' || + value === 'command_execution' || + value === 'non_source_work' || + value === 'auto_worker' + ? value + : value === 'plan_registration' + ? 'plan' + : value === 'general_development' + ? 'auto_worker' + : 'none'; +} + +function isErrorLogReviewRequest(note) { + const text = String(note ?? '').replace(/\s+/g, ' ').trim(); + + if (!text) { + return false; + } + + return /์—๋Ÿฌ\s*๋กœ๊ทธ|error\s*log/i.test(text) && /plan\s*๊ฒŒ์‹œํŒ|plan๊ฒŒ์‹œํŒ|ํ”Œ๋žœ\s*๊ฒŒ์‹œํŒ/i.test(text) && /๋“ฑ๋ก|ํ™•์ธ/.test(text); +} + +function shouldProcessAsPlanDocument(item) { + return normalizeAutomationType(item?.automationType) === 'plan' && isErrorLogReviewRequest(item?.note); +} + +function shouldForceNoSourceChange(item) { + const automationType = normalizeAutomationType(item?.automationType); + return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note); +} + +function shouldSkipSourceChangeRequirement(item) { + const automationType = normalizeAutomationType(item?.automationType); + return automationType === 'command_execution' || automationType === 'non_source_work' || isErrorLogReviewRequest(item?.note); +} + +function normalizeDateBoundary(value) { + if (!value) { + return null; + } + + const date = value instanceof Date ? value : new Date(String(value)); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return date; +} + +function formatIsoTimestamp(value) { + const date = normalizeDateBoundary(value); + return date ? date.toISOString() : String(value ?? ''); +} + +function normalizeRequestPathGroup(requestPath) { + const normalized = String(requestPath ?? '') + .trim() + .toLowerCase() + .replace(/\?.*$/u, '') + .replace(/\/\d+(?=\/|$)/gu, '/:id') + .replace(/[0-9a-f]{8,}(?=\/|$)/giu, ':id'); + + if (!normalized) { + return 'unknown'; + } + + const segments = normalized + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + .slice(0, 3); + + return segments.length > 0 ? `/${segments.join('/')}` : normalized; +} + +function buildErrorLogPlanFingerprint(log) { + return createHash('sha1') + .update( + [ + String(log.source ?? '').trim().toLowerCase(), + String(log.errorType ?? '').trim().toLowerCase(), + log.statusCode == null ? '' : String(log.statusCode), + ].join('||'), + ) + .digest('hex') + .slice(0, 12); +} + +function buildErrorLogPlanWorkId(log) { + return `error-fix-${buildErrorLogPlanFingerprint(log)}`; +} + +function buildErrorLogPlanCandidates(logs) { + const grouped = new Map(); + + for (const log of logs) { + const fingerprint = createHash('sha1') + .update( + [ + String(log.source ?? '').trim().toLowerCase(), + String(log.errorType ?? '').trim().toLowerCase(), + String(log.errorName ?? '').trim().toLowerCase(), + log.statusCode == null ? '' : String(log.statusCode), + normalizeRequestPathGroup(log.requestPath), + ].join('||'), + ) + .digest('hex') + .slice(0, 12); + const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime() ?? 0; + const existing = grouped.get(fingerprint); + const requestPathGroup = normalizeRequestPathGroup(log.requestPath); + const errorMessage = String(log.errorMessage ?? '').trim(); + + if (!existing) { + grouped.set(fingerprint, { + sample: log, + count: 1, + firstCreatedAt: log.createdAt, + firstTimeMs: createdAtMs, + lastCreatedAt: log.createdAt, + lastTimeMs: createdAtMs, + requestPathGroup, + requestPaths: log.requestPath ? new Set([String(log.requestPath).trim()]) : new Set(), + errorMessages: errorMessage ? new Set([errorMessage]) : new Set(), + errorNames: log.errorName ? new Set([String(log.errorName).trim()]) : new Set(), + sampleLogIds: log.id != null ? [log.id] : [], + }); + continue; + } + + existing.count += 1; + if (log.requestPath) { + existing.requestPaths.add(String(log.requestPath).trim()); + } + if (errorMessage) { + existing.errorMessages.add(errorMessage); + } + if (log.errorName) { + existing.errorNames.add(String(log.errorName).trim()); + } + if (log.id != null && existing.sampleLogIds.length < 5) { + existing.sampleLogIds.push(log.id); + } + + if (createdAtMs <= existing.firstTimeMs) { + existing.firstCreatedAt = log.createdAt; + existing.firstTimeMs = createdAtMs; + } + + if (createdAtMs >= existing.lastTimeMs) { + existing.sample = log; + existing.lastCreatedAt = log.createdAt; + existing.lastTimeMs = createdAtMs; + } + } + + return [...grouped.entries()] + .map(([fingerprint, entry]) => ({ + fingerprint, + workId: buildErrorLogPlanWorkId(entry.sample), + source: entry.sample.source, + sourceLabel: entry.sample.sourceLabel, + errorType: entry.sample.errorType, + errorName: entry.sample.errorName, + errorMessage: entry.sample.errorMessage, + requestPath: entry.sample.requestPath, + requestPathGroup: entry.requestPathGroup, + requestPaths: [...entry.requestPaths].filter(Boolean).slice(0, 5), + statusCode: entry.sample.statusCode, + count: entry.count, + firstCreatedAt: entry.firstCreatedAt, + lastCreatedAt: entry.lastCreatedAt, + sampleLogId: entry.sample.id, + sampleLogIds: entry.sampleLogIds, + errorNames: [...entry.errorNames].filter(Boolean).slice(0, 5), + representativeMessages: [...entry.errorMessages].filter(Boolean).slice(0, 5), + })) + .sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + + const leftLastTime = normalizeDateBoundary(left.lastCreatedAt)?.getTime() ?? 0; + const rightLastTime = normalizeDateBoundary(right.lastCreatedAt)?.getTime() ?? 0; + + if (rightLastTime !== leftLastTime) { + return rightLastTime - leftLastTime; + } + + return Number(right.sampleLogId ?? 0) - Number(left.sampleLogId ?? 0); + }); +} + +function mergeErrorLogPlanCandidateBucket(bucket, bucketIndex) { + const sortedBucket = [...bucket].sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + + return String(left.workId ?? '').localeCompare(String(right.workId ?? '')); + }); + const representative = sortedBucket[0]; + const uniqueFingerprints = [...new Set(sortedBucket.map((candidate) => candidate.fingerprint).filter(Boolean))].sort(); + const mergedFingerprint = createHash('sha1') + .update(uniqueFingerprints.join('||')) + .digest('hex') + .slice(0, 12); + const firstCreatedAt = sortedBucket + .map((candidate) => normalizeDateBoundary(candidate.firstCreatedAt)?.getTime() ?? Number.POSITIVE_INFINITY) + .reduce((min, value) => Math.min(min, value), Number.POSITIVE_INFINITY); + const lastCreatedAt = sortedBucket + .map((candidate) => normalizeDateBoundary(candidate.lastCreatedAt)?.getTime() ?? 0) + .reduce((max, value) => Math.max(max, value), 0); + const requestPaths = [...new Set(sortedBucket.flatMap((candidate) => candidate.requestPaths ?? []).filter(Boolean))].slice(0, 8); + const representativeMessages = [...new Set(sortedBucket.flatMap((candidate) => candidate.representativeMessages ?? []).filter(Boolean))].slice(0, 8); + const errorNames = [...new Set(sortedBucket.flatMap((candidate) => candidate.errorNames ?? []).filter(Boolean))].slice(0, 8); + const groupedScopes = sortedBucket + .slice(0, 8) + .map((candidate) => { + const parts = [candidate.sourceLabel || candidate.source, candidate.errorType]; + if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') { + parts.push(candidate.requestPathGroup); + } + return parts.filter(Boolean).join(' / '); + }) + .filter(Boolean); + + return { + fingerprint: mergedFingerprint, + workId: `error-fix-bundle-${mergedFingerprint}`, + source: representative.source, + sourceLabel: representative.sourceLabel, + errorType: sortedBucket.length > 1 ? '๋‹ค์ค‘ ์—๋Ÿฌ ๋ฌถ์Œ' : representative.errorType, + errorName: representative.errorName, + errorMessage: representative.errorMessage, + requestPath: representative.requestPath, + requestPathGroup: representative.requestPathGroup, + requestPaths, + statusCode: representative.statusCode, + count: sortedBucket.reduce((sum, candidate) => sum + Number(candidate.count ?? 0), 0), + firstCreatedAt: Number.isFinite(firstCreatedAt) ? new Date(firstCreatedAt).toISOString() : representative.firstCreatedAt, + lastCreatedAt: lastCreatedAt > 0 ? new Date(lastCreatedAt).toISOString() : representative.lastCreatedAt, + sampleLogId: representative.sampleLogId, + sampleLogIds: [...new Set(sortedBucket.flatMap((candidate) => candidate.sampleLogIds ?? []).filter((value) => value != null))].slice(0, 8), + errorNames, + representativeMessages, + groupedScopes, + groupedCandidateCount: sortedBucket.length, + bucketIndex, + }; +} + +function coalesceErrorLogPlanCandidates(candidates, maxGroups = MAX_ERROR_LOG_PLAN_REGISTRATIONS) { + const sortedCandidates = [...candidates].sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + + return String(left.workId ?? '').localeCompare(String(right.workId ?? '')); + }); + + if (sortedCandidates.length <= maxGroups) { + return sortedCandidates; + } + + const bucketSize = Math.ceil(sortedCandidates.length / maxGroups); + const merged = []; + + for (let index = 0; index < sortedCandidates.length; index += bucketSize) { + const bucket = sortedCandidates.slice(index, index + bucketSize); + if (bucket.length === 0) { + continue; + } + merged.push(mergeErrorLogPlanCandidateBucket(bucket, merged.length + 1)); + } + + return merged.slice(0, maxGroups); +} + +function filterLogsWithinRange(logs, rangeStart, rangeEnd) { + const startTime = rangeStart.getTime(); + const endTime = rangeEnd.getTime(); + + return logs.filter((log) => { + const createdAtMs = normalizeDateBoundary(log.createdAt)?.getTime(); + return createdAtMs != null && createdAtMs >= startTime && createdAtMs <= endTime; + }); +} + +function formatErrorLogPlanNote(candidate, rangeStart, rangeEnd) { + const lines = [ + `์กฐํšŒ ๊ตฌ๊ฐ„: ${formatIsoTimestamp(rangeStart)} ~ ${formatIsoTimestamp(rangeEnd)}`, + `๋ฐœ์ƒ ๊ฑด์ˆ˜: ${candidate.count}๊ฑด`, + `์ตœ๊ทผ ๋ฐœ์ƒ: ${formatIsoTimestamp(candidate.lastCreatedAt)}`, + `์ตœ์ดˆ ๋ฐœ์ƒ: ${formatIsoTimestamp(candidate.firstCreatedAt)}`, + `์—๋Ÿฌ ์œ ํ˜•: ${candidate.errorType}`, + ]; + + if (candidate.errorName) { + lines.push(`์—๋Ÿฌ ์ด๋ฆ„: ${candidate.errorName}`); + } + + if (candidate.sourceLabel || candidate.source) { + lines.push(`๋ฐœ์ƒ ์œ„์น˜: ${candidate.sourceLabel || candidate.source}`); + } + + if (candidate.groupedCandidateCount && candidate.groupedCandidateCount > 1) { + lines.push(`๋ฌถ์ธ ์—๋Ÿฌ ๊ทธ๋ฃน: ${candidate.groupedCandidateCount}๊ฐœ`); + } + + if (candidate.requestPathGroup && candidate.requestPathGroup !== 'unknown') { + lines.push(`์ฃผ์š” ๊ฒฝ๋กœ ๊ทธ๋ฃน: ${candidate.requestPathGroup}`); + } + + if (Array.isArray(candidate.requestPaths) && candidate.requestPaths.length > 0) { + lines.push(`๋Œ€ํ‘œ ๊ฒฝ๋กœ: ${candidate.requestPaths.join(', ')}`); + } + + if (candidate.statusCode != null) { + lines.push(`์ƒํƒœ ์ฝ”๋“œ: ${candidate.statusCode}`); + } + + if (Array.isArray(candidate.representativeMessages) && candidate.representativeMessages.length > 0) { + lines.push('๋Œ€ํ‘œ ๋ฉ”์‹œ์ง€:'); + lines.push(...candidate.representativeMessages.map((message, index) => `${index + 1}. ${message}`)); + } else { + lines.push(`๋Œ€ํ‘œ ๋ฉ”์‹œ์ง€: ${String(candidate.errorMessage ?? '').trim()}`); + } + + if (Array.isArray(candidate.sampleLogIds) && candidate.sampleLogIds.length > 0) { + lines.push(`๋Œ€ํ‘œ ๋กœ๊ทธ ID: ${candidate.sampleLogIds.join(', ')}`); + } else { + lines.push(`๋Œ€ํ‘œ ๋กœ๊ทธ ID: ${candidate.sampleLogId}`); + } + + if (Array.isArray(candidate.groupedScopes) && candidate.groupedScopes.length > 0) { + lines.push('๋ฌถ์ธ ์—๋Ÿฌ ๋ฒ”์œ„:'); + lines.push(...candidate.groupedScopes.map((scope, index) => `${index + 1}. ${scope}`)); + } + lines.push(''); + lines.push('์ฒ˜๋ฆฌ ์š”์ฒญ:'); + lines.push('1. ์žฌํ˜„ ๊ฒฝ๋กœ์™€ ์˜ํ–ฅ ๋ฒ”์œ„๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.'); + lines.push('2. ์ˆ˜์ •์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋ณ„๋„ Plan์œผ๋กœ ์†Œ์Šค ์ž‘์—…์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.'); + lines.push('3. ํ…Œ์ŠคํŠธ์™€ ์žฌ๋ฐœ ๋ฐฉ์ง€ ํ•„์š” ์—ฌ๋ถ€๋ฅผ ๊ฒ€ํ† ํ•ฉ๋‹ˆ๋‹ค.'); + + return lines.join('\n'); +} + +function stripAnsi(text) { + return String(text ?? '').replace(/\u001B\[[0-9;]*m/g, ''); +} + +function normalizeErrorSummaryLine(line) { + return stripAnsi(line) + .replace(/^\[plan-progress\]\s*/u, '') + .replace(/^\d{4}-\d{2}-\d{2}T\S+\s+ERROR\s+[^:]+:\s*/u, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function summarizeFailureOutput(output, fallback) { + const normalizedLines = String(output ?? '') + .split('\n') + .map((line) => normalizeErrorSummaryLine(line)) + .filter(Boolean) + .filter((line) => !ERROR_SUMMARY_NOISE_PATTERN.test(line)); + + const bestLine = + normalizedLines.filter((line) => ERROR_SUMMARY_LINE_PATTERN.test(line)).at(-1) ?? + normalizedLines.at(-1) ?? + String(fallback ?? '').trim(); + + const summary = bestLine.replace(/\s+/g, ' ').trim(); + const normalizedSummary = summary.slice(0, ERROR_SUMMARY_MAX_LENGTH) || '์ž๋™ ์ž‘์—… ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'; + + if (!CODEX_HOME_PERMISSION_PATTERN.test(normalizedSummary)) { + return normalizedSummary; + } + + const codexHome = process.env.CODEX_HOME?.trim() || '(unset)'; + const templateHome = process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || '(unset)'; + return `${normalizedSummary} [hint: CODEX_HOME=${codexHome}, template=${templateHome}]`; +} + +function isTransientCodexExecFailure(error) { + const message = error instanceof Error ? error.message : String(error ?? ''); + return CODEX_EXEC_TRANSIENT_FAILURE_PATTERN.test(message); +} + +async function wait(ms) { + await new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function prepareWritableCodexHome(tempDir) { + const writableCodexHome = process.env.CODEX_HOME?.trim() || path.join(tempDir, '.codex'); + const sourceCodexHome = + process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || + process.env.CODEX_HOME_TEMPLATE?.trim() || + path.join(process.env.HOME ?? '/root', '.codex'); + + await mkdir(writableCodexHome, { recursive: true }); + + for (const relativePath of CODEX_HOME_RUNTIME_PATHS) { + const sourcePath = path.join(sourceCodexHome, relativePath); + const targetPath = path.join(writableCodexHome, relativePath); + + try { + await access(sourcePath); + } catch { + continue; + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath, { recursive: true, force: true }); + } + + return writableCodexHome; +} +function summarizeRequest(note, limit = PROMPT_REQUEST_SUMMARY_LIMIT) { + const text = String(note ?? '') + .replace(/\s+/g, ' ') + .trim(); + + if (!text) { + return '์š”์ฒญ ๋‚ด์šฉ ์—†์Œ'; + } + + if (text.length <= limit) { + return text; + } + + return `${text.slice(0, Math.max(0, limit - 1)).trim()}โ€ฆ`; +} + +function escapeTemplateValue(value) { + return encodeURIComponent(String(value ?? '').trim()); +} + +function buildPreviewUrl(item, commitHash = '') { + if (previewUrlTemplate) { + return previewUrlTemplate + .replaceAll('{branch}', escapeTemplateValue(item.assignedBranch ?? '')) + .replaceAll('{commit}', escapeTemplateValue(commitHash)) + .replaceAll('{workId}', escapeTemplateValue(item.workId ?? '')) + .replaceAll('{planId}', escapeTemplateValue(item.id ?? '')); + } + + if (previewBaseUrl) { + return previewBaseUrl; + } + + return null; +} + +async function request(pathname, init) { + const headers = { + 'Content-Type': 'application/json', + ...(accessToken ? { 'X-Access-Token': accessToken } : {}), + ...(init?.headers ?? {}), + }; + const response = await fetch(`${apiBaseUrl}${pathname}`, { + headers, + ...init, + }); + + const data = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(data?.message ?? `Plan API ์š”์ฒญ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค: ${pathname}`); + } + + return data; +} + +async function fetchPlanActionHistories(planItemId) { + const data = await request(`/plan/items/${planItemId}/actions`); + return data.items ?? []; +} + +async function fetchPlanIssueHistories(planItemId) { + const data = await request(`/plan/items/${planItemId}/issues`); + return data.items ?? []; +} + +function formatRecentActionHistories(histories) { + if (!histories.length) { + return '์—†์Œ'; + } + + return histories + .slice(0, 5) + .map( + (history, index) => + `${index + 1}. [${history.actionType}] ${history.createdAt}\n${String(history.note ?? '').trim()}`, + ) + .join('\n\n'); +} + +function formatRecentIssueHistories(histories) { + if (!histories.length) { + return '์—†์Œ'; + } + + return histories + .slice(0, 5) + .map((history, index) => { + const actionNote = history.actionNote ? `\n์กฐ์น˜์ด๋ ฅ:\n${String(history.actionNote).trim()}` : ''; + return `${index + 1}. ${history.issueTag} / ${history.createdAt}\n${String(history.message ?? '').trim()}${actionNote}`; + }) + .join('\n\n'); +} + +async function runCommand(command, args) { + if (command === 'git') { + await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.name', gitUserName], { + encoding: 'utf8', + maxBuffer: EXEC_MAX_BUFFER, + }); + await execFileAsync('git', ['-c', `safe.directory=${repoPath}`, '-C', repoPath, 'config', 'user.email', gitUserEmail], { + encoding: 'utf8', + maxBuffer: EXEC_MAX_BUFFER, + }); + return execFileAsync('git', ['-c', `safe.directory=${repoPath}`, ...args], { + cwd: repoPath, + encoding: 'utf8', + maxBuffer: EXEC_MAX_BUFFER, + }); + } + + return execFileAsync(command, args, { + cwd: repoPath, + encoding: 'utf8', + maxBuffer: EXEC_MAX_BUFFER, + }); +} + +async function hasGitChanges() { + const { stdout } = await runCommand('git', ['status', '--porcelain']); + return Boolean(stdout.trim()); +} + +async function getHeadCommit() { + const { stdout } = await runCommand('git', ['rev-parse', 'HEAD']); + return stdout.trim(); +} + +async function getHeadCommitSubject() { + const { stdout } = await runCommand('git', ['show', '--format=%s', '--no-patch', 'HEAD']); + return stdout.trim(); +} + +async function listHeadChangedFiles() { + const { stdout } = await runCommand('git', ['show', '--format=', '--name-only', '--find-renames', 'HEAD']); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +async function listChangedFiles() { + const { stdout } = await runCommand('git', ['status', '--short']); + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +function normalizeChangedPaths(changedFiles) { + return changedFiles + .map((line) => line.replace(/^[A-Z?]{1,2}\s+/, '').trim()) + .map((line) => line.split(' -> ').at(-1) ?? line) + .map((line) => line.replace(/\\/g, '/')); +} + +function inferSourceLanguage(filePath) { + const normalizedPath = String(filePath ?? '').toLowerCase(); + + if (normalizedPath.endsWith('.tsx')) { + return 'tsx'; + } + + if (normalizedPath.endsWith('.ts')) { + return 'typescript'; + } + + if (normalizedPath.endsWith('.jsx')) { + return 'jsx'; + } + + if (normalizedPath.endsWith('.js')) { + return 'javascript'; + } + + if (normalizedPath.endsWith('.json') || normalizedPath.endsWith('.jsonl')) { + return 'json'; + } + + if (normalizedPath.endsWith('.yml') || normalizedPath.endsWith('.yaml')) { + return 'yaml'; + } + + if (normalizedPath.endsWith('.css') || normalizedPath.endsWith('.scss') || normalizedPath.endsWith('.less')) { + return 'css'; + } + + if (normalizedPath.endsWith('.html') || normalizedPath.endsWith('.htm')) { + return 'html'; + } + + if (normalizedPath.endsWith('.diff') || normalizedPath.endsWith('.patch')) { + return 'diff'; + } + + if (normalizedPath.endsWith('.sh') || normalizedPath.endsWith('.bash') || normalizedPath.endsWith('.zsh')) { + return 'bash'; + } + + if (normalizedPath.endsWith('.py')) { + return 'python'; + } + + if (normalizedPath.endsWith('.md') || normalizedPath.endsWith('.mdx')) { + return 'markdown'; + } + + return 'text'; +} + +async function collectSourceFileSnapshots() { + const { stdout } = await runCommand('git', ['show', '--format=', '--name-status', '--find-renames', 'HEAD']); + const lines = stdout + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean); + + const snapshots = await Promise.all( + lines.map(async (line) => { + const columns = line.split('\t').filter(Boolean); + + if (columns.length < 2) { + return null; + } + + const rawStatus = columns[0] ?? ''; + const statusCode = rawStatus.charAt(0).toUpperCase(); + const isRename = statusCode === 'R'; + const previousPath = isRename ? (columns[1] ?? null) : null; + const pathValue = isRename ? columns[2] : columns[1]; + const normalizedPath = String(pathValue ?? '').replace(/\\/g, '/'); + + if (!normalizedPath) { + return null; + } + + const normalizedStatus = + statusCode === 'A' + ? 'added' + : statusCode === 'M' + ? 'modified' + : statusCode === 'D' + ? 'deleted' + : statusCode === 'R' + ? 'renamed' + : 'unknown'; + + if (normalizedStatus === 'deleted') { + return { + path: normalizedPath, + previousPath, + status: normalizedStatus, + language: 'text', + content: '์‚ญ์ œ๋œ ํŒŒ์ผ์ด๋ผ ๋ณธ๋ฌธ ์Šค๋ƒ…์ƒท์ด ์—†์Šต๋‹ˆ๋‹ค.', + }; + } + + if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) { + return { + path: normalizedPath, + previousPath, + status: 'binary', + language: 'text', + content: 'ํ…์ŠคํŠธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ์ž…๋‹ˆ๋‹ค.', + }; + } + + const absolutePath = path.resolve(repoPath, normalizedPath); + + try { + const content = await readFile(absolutePath, 'utf8'); + + if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) { + return { + path: normalizedPath, + previousPath, + status: normalizedStatus, + language: inferSourceLanguage(normalizedPath), + content: `ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ปค์„œ ์ „์ฒด ์Šค๋ƒ…์ƒท์„ ์ €์žฅํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. (${Math.round( + Buffer.byteLength(content, 'utf8') / 1024, + )}KB)`, + }; + } + + return { + path: normalizedPath, + previousPath, + status: normalizedStatus, + language: inferSourceLanguage(normalizedPath), + content, + }; + } catch { + return { + path: normalizedPath, + previousPath, + status: normalizedStatus, + language: inferSourceLanguage(normalizedPath), + content: 'ํŒŒ์ผ ์Šค๋ƒ…์ƒท์„ ์ฝ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }; + } + }), + ); + + return snapshots.filter(Boolean); +} + +async function collectWorkingTreeSourceSnapshots(changedPaths) { + const uniquePaths = [...new Set((changedPaths ?? []).map((value) => String(value ?? '').trim()).filter(Boolean))]; + + const snapshots = await Promise.all( + uniquePaths.map(async (normalizedPath) => { + const absolutePath = path.resolve(repoPath, normalizedPath); + + if (!SOURCE_SNAPSHOT_TEXT_PATTERN.test(normalizedPath)) { + return { + path: normalizedPath, + previousPath: null, + status: 'binary', + language: 'text', + content: 'ํ…์ŠคํŠธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ํŒŒ์ผ์ž…๋‹ˆ๋‹ค.', + }; + } + + try { + const content = await readFile(absolutePath, 'utf8'); + + if (Buffer.byteLength(content, 'utf8') > SOURCE_SNAPSHOT_MAX_BYTES) { + return { + path: normalizedPath, + previousPath: null, + status: 'modified', + language: inferSourceLanguage(normalizedPath), + content: `ํŒŒ์ผ ํฌ๊ธฐ๊ฐ€ ์ปค์„œ ์•ž๋ถ€๋ถ„๋งŒ ๋ณด๊ด€ํ–ˆ์Šต๋‹ˆ๋‹ค.\n\n${content.slice(0, SOURCE_SNAPSHOT_MAX_BYTES)}`, + }; + } + + return { + path: normalizedPath, + previousPath: null, + status: 'modified', + language: inferSourceLanguage(normalizedPath), + content, + }; + } catch { + return { + path: normalizedPath, + previousPath: null, + status: 'deleted', + language: 'text', + content: '์‚ญ์ œ๋œ ํŒŒ์ผ์ด๋ผ ๋ณธ๋ฌธ ์Šค๋ƒ…์ƒท์ด ์—†์Šต๋‹ˆ๋‹ค.', + }; + } + }), + ); + + return snapshots.filter(Boolean); +} + +async function hasLocalBranch(branchName) { + try { + await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branchName}`]); + return true; + } catch { + return false; + } +} + +async function getGitDirPath() { + const { stdout } = await runCommand('git', ['rev-parse', '--git-dir']); + const gitDir = stdout.trim() || '.git'; + return path.resolve(repoPath, gitDir); +} + +async function exists(targetPath) { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + +async function tryGitCleanup(args) { + try { + await runCommand('git', args); + return true; + } catch { + return false; + } +} + +async function clearInterruptedGitOperations() { + const gitDirPath = await getGitDirPath(); + const checks = await Promise.all([ + exists(path.join(gitDirPath, 'MERGE_HEAD')), + exists(path.join(gitDirPath, 'rebase-merge')), + exists(path.join(gitDirPath, 'rebase-apply')), + exists(path.join(gitDirPath, 'CHERRY_PICK_HEAD')), + exists(path.join(gitDirPath, 'REVERT_HEAD')), + exists(path.join(gitDirPath, 'BISECT_LOG')), + ]); + + const [hasMergeHead, hasRebaseMerge, hasRebaseApply, hasCherryPickHead, hasRevertHead, hasBisectLog] = checks; + + if (hasMergeHead) { + await tryGitCleanup(['merge', '--abort']); + } + + if (hasRebaseMerge || hasRebaseApply) { + await tryGitCleanup(['rebase', '--abort']); + } + + if (hasCherryPickHead) { + await tryGitCleanup(['cherry-pick', '--abort']); + } + + if (hasRevertHead) { + await tryGitCleanup(['revert', '--abort']); + } + + if (hasBisectLog) { + await tryGitCleanup(['bisect', 'reset']); + } +} + +async function ensureCleanWorkingTree() { + if (localMainMode) { + reportProgress('๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ๋ผ git reset/clean์€ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค.'); + return; + } + + reportProgress('์ด์ „ git ์ค‘๊ฐ„ ์ƒํƒœ์™€ ์ž‘์—… ํŠธ๋ฆฌ๋ฅผ ์ •๋ฆฌํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + await clearInterruptedGitOperations(); + await runCommand('git', ['reset', '--hard']); + await runCommand('git', ['clean', '-fd']); +} + +async function ensureWorkingBranch(item) { + if (localMainMode) { + reportProgress(`๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ๋กœ ${item.assignedBranch || 'main'} ์ž‘์—…๋ณธ์„ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.`); + return; + } + + const branchName = item.assignedBranch; + const baseBranch = 'main'; + + if (await hasLocalBranch(branchName)) { + await runCommand('git', ['switch', branchName]); + return; + } + + await runCommand('git', ['switch', baseBranch]); + await runCommand('git', ['switch', '-C', branchName, baseBranch]); +} + +async function commitAndPushBranch(item, summary) { + if (localMainMode) { + throw new Error('๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๋ชจ๋“œ์—์„œ๋Š” commit/push๋ฅผ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + + const commitMessage = `plan(${item.workId}): ${summary}`.slice(0, 120); + await runCommand('git', ['add', '-A']); + await runCommand('git', ['commit', '-m', commitMessage]); + await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]); + const { stdout } = await runCommand('git', ['rev-parse', '--short', 'HEAD']); + return { + commitHash: stdout.trim(), + commitMessage, + }; +} + +function parseBoardPostResult(result) { + const text = String(result ?? '').trim(); + + if (!text.startsWith('BOARD_POST:')) { + return null; + } + + const rawPayload = text + .replace(/^BOARD_POST:\s*/u, '') + .replace(/^```(?:json)?\s*/u, '') + .replace(/\s*```$/u, '') + .trim(); + + try { + const parsed = JSON.parse(rawPayload); + const title = String(parsed?.title ?? '').trim(); + const content = String(parsed?.content ?? '').trim(); + + if (!title || !content) { + throw new Error('๊ฒŒ์‹œ๊ธ€ ์ œ๋ชฉ ๋˜๋Š” ๋ณธ๋ฌธ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'); + } + + return { title, content }; + } catch (error) { + const message = error instanceof Error ? error.message : 'JSON ํŒŒ์‹ฑ ์‹คํŒจ'; + throw new Error(`๊ฒŒ์‹œํŒ ์ž‘์„ฑ ๊ฒฐ๊ณผ๋ฅผ ํ•ด์„ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ${message}`); + } +} + +function parseRecoveredCommitResult(result) { + const text = String(result ?? '').trim(); + + if (!text.startsWith('RECOVERED_COMMIT:')) { + return null; + } + + const lines = text.split('\n'); + const commitHash = lines[0]?.replace(/^RECOVERED_COMMIT:\s*/u, '').trim() ?? ''; + const summary = lines[1]?.trim() || 'Codex๊ฐ€ ์ƒ์„ฑํ•œ ๋กœ์ปฌ ์ปค๋ฐ‹์„ ๋ณต๊ตฌํ–ˆ์Šต๋‹ˆ๋‹ค.'; + const originalError = lines.slice(2).join('\n').replace(/^Original error:\s*/u, '').trim(); + + return { + commitHash, + summary, + originalError, + }; +} + +async function runCodexForPlan(item) { + reportProgress('์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ๊ณผ ์ด์Šˆ ์ด๋ ฅ์„ ์กฐํšŒํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const tempDir = await mkdtemp(path.join(tmpdir(), 'plan-codex-')); + const outputFile = path.join(tempDir, `plan-${item.id}.txt`); + const writableCodexHome = await prepareWritableCodexHome(tempDir); + const [recentActions, recentIssues] = await Promise.all([ + fetchPlanActionHistories(item.id), + fetchPlanIssueHistories(item.id), + ]); + const requestSummary = summarizeRequest(item.note); + + const prompt = [ + '๋‹น์‹ ์€ ํ˜„์žฌ ์ €์žฅ์†Œ์—์„œ Plan ํ•ญ๋ชฉ์„ ์ฒ˜๋ฆฌํ•˜๋Š” Codex ์‹คํ–‰๊ธฐ์ž…๋‹ˆ๋‹ค.', + `์ž‘์—… ID: ${item.workId}`, + `์š”์ฒญ ์š”์•ฝ: ${requestSummary}`, + `์ƒ์„ธ ์š”์ฒญ:\n${item.note || '(๋น„์–ด ์žˆ์Œ)'}`, + `์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ:\n${formatRecentActionHistories(recentActions)}`, + `์ตœ๊ทผ ์ด์Šˆ ์ด๋ ฅ:\n${formatRecentIssueHistories(recentIssues)}`, + '๊ทœ์น™:', + '0. git commit, git push, release/main merge๋Š” ์ง์ ‘ ์‹คํ–‰ํ•˜์ง€ ๋งˆ์„ธ์š”. ํŒŒ์ผ ๋ณ€๊ฒฝ๋งŒ ๋‚จ๊ธฐ๋ฉด ์ด ์‹คํ–‰๊ธฐ๊ฐ€ ์ปค๋ฐ‹/ํ‘ธ์‹œ/๋ฐ˜์˜์„ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.', + '1. ์š”์ฒญ์ด ๊ตฌ์ฒด์ ์ด๊ณ  ์‹ค์ œ ์ฝ”๋“œ ์ž‘์—…์ด ํ•„์š”ํ•˜๋ฉด ์ €์žฅ์†Œ๋ฅผ ์ˆ˜์ •ํ•˜๊ณ  ์ตœ์ข… ๋‹ต๋ณ€์„ ๋ฐ˜๋“œ์‹œ "DONE: "์œผ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”.', + '2. ์š”์ฒญ์ด ํ…Œ์ŠคํŠธ ์ˆ˜์ค€์ด๊ฑฐ๋‚˜ ๋ณ„๋„ ์ž‘์—…์ด ๋ถˆํ•„์š”ํ•˜๋ฉด ์ฝ”๋“œ๋ฅผ ์ˆ˜์ •ํ•˜์ง€ ๋ง๊ณ  ์ตœ์ข… ๋‹ต๋ณ€์„ ๋ฐ˜๋“œ์‹œ "NOOP: "์œผ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”.', + '3. ์ตœ์ข… ๋‹ต๋ณ€์€ ํ•œ๊ตญ์–ด ํ•œ๋‘ ๋ฌธ์žฅ์œผ๋กœ ๊ฐ„๋‹จํžˆ ์ž‘์„ฑํ•˜์„ธ์š”.', + '4. ์ตœ๊ทผ ์กฐ์น˜ ์ด๋ ฅ์— ์žฌ์ฒ˜๋ฆฌ๋‚˜ ๋ณด์™„ ์ง€์‹œ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ ๋‚ด์šฉ์„ ์šฐ์„  ๋ฐ˜์˜ํ•ด ๋‹ค์‹œ ์ž‘์—…ํ•˜์„ธ์š”.', + '5. ์‘๋‹ต ํ†ค์€ CLI์ฒ˜๋Ÿผ ์งง๊ณ  ์ง์ ‘์ ์œผ๋กœ ์ž‘์„ฑํ•˜์„ธ์š”. ๋‘๋ฃจ๋ญ‰์ˆ ํ•œ ์„ค๋ช…์ด๋‚˜ ๋™๋ฌธ์„œ๋‹ต์„ ํ”ผํ•˜์„ธ์š”.', + '6. ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ™•์ธ์ด ํ•„์š”ํ•œ ์ž‘์—…์ด๋ฉด preview ๋งํฌ๊ฐ€ ํ•„์š”ํ•œ์ง€ ํŒ๋‹จํ•˜๊ณ , ์ด๋ฏธ ์ •ํ•ด์ง„ preview ์ฃผ์†Œ ๊ทœ์น™์ด ์žˆ์œผ๋ฉด ๊ทธ ๋งํฌ๋ฅผ ์ตœ์ข… ๋‹ต๋ณ€์—๋„ ํ•จ๊ป˜ ์ ์œผ์„ธ์š”.', + shouldForceNoSourceChange(item) + ? '7. ์ด๋ฒˆ ์š”์ฒญ์€ ์„ ํƒ๋œ ์ž๋™ํ™” ์ฒ˜๋ฆฌ ์œ ํ˜•์ƒ ์ €์žฅ์†Œ ํŒŒ์ผ์„ ์ˆ˜์ •ํ•˜๋ฉด ์•ˆ ๋ฉ๋‹ˆ๋‹ค. ํ•„์š”ํ•œ ์กฐํšŒ/๋ช…๋ น/์ •๋ฆฌ๋งŒ ์ˆ˜ํ–‰ํ•˜๊ณ  ์ตœ์ข… ๋‹ต๋ณ€์€ ๋ฐ˜๋“œ์‹œ "NOOP: "์œผ๋กœ ์‹œ์ž‘ํ•˜์„ธ์š”.' + : '', + isBoardDraftOnlyRequest(item.note) + ? '8. ์ด๋ฒˆ ์š”์ฒญ์€ ๊ฒŒ์‹œํŒ ์ž‘์„ฑ ์ „์šฉ์ž…๋‹ˆ๋‹ค. ์ €์žฅ์†Œ ํŒŒ์ผ์„ ์ˆ˜์ •ํ•˜์ง€ ๋ง๊ณ , ์ตœ์ข… ๋‹ต๋ณ€์„ ๋ฐ˜๋“œ์‹œ `BOARD_POST:` ๋‹ค์Œ ์ค„์— `{"title":"...","content":"..."}` JSON ํ•˜๋‚˜๋งŒ ๋ถ™์—ฌ์„œ ์ž‘์„ฑํ•˜์„ธ์š”. ๊ฒŒ์‹œ๊ธ€ ๋ณธ๋ฌธ์—๋Š” ์‹ค์ œ ์ ‘์ˆ˜ ํ›„ ์ž‘์—…์ž๊ฐ€ ๋ฐ”๋กœ ์‹คํ–‰ํ•  ๋‚ด์šฉ๋งŒ ์ž‘์„ฑํ•˜๊ณ , ๊ฒ€ํ† ์šฉ/๋ฏธ์ ‘์ˆ˜/์ž๋™ํ™” ์ ‘์ˆ˜ ๋Œ€๊ธฐ ๊ฐ™์€ ์šด์˜ ์ƒํƒœ ๋ฌธ๊ตฌ๋Š” ๋„ฃ์ง€ ๋งˆ์„ธ์š”.' + : '', + ].join('\n'); + + const runCodexExecOnce = async (attempt) => { + if (attempt > 1) { + reportProgress(`Codex ๋‚ด๋ถ€ ์ฑ„๋„ ์˜ค๋ฅ˜๋กœ ์ž๋™ ์ž‘์—…์„ ์žฌ์‹œ๋„ํ•ฉ๋‹ˆ๋‹ค. (${attempt}/${CODEX_EXEC_MAX_ATTEMPTS})`); + } + + return await new Promise((resolve, reject) => { + let settled = false; + const child = spawn( + codexBin, + [ + 'exec', + '--dangerously-bypass-approvals-and-sandbox', + '-C', + repoPath, + '-o', + outputFile, + '-', + ], + { + cwd: repoPath, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + CODEX_HOME: writableCodexHome, + }, + }, + ); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (chunk) => { + const text = String(chunk); + stdout = (stdout + text).slice(-STREAM_CAPTURE_LIMIT); + process.stderr.write(text); + }); + + child.stderr?.on('data', (chunk) => { + const text = String(chunk); + stderr = (stderr + text).slice(-STREAM_CAPTURE_LIMIT); + process.stderr.write(text); + }); + + child.stdin?.end(prompt); + + child.on('error', (error) => { + if (settled) { + return; + } + + settled = true; + reject(error); + }); + child.on('close', (code, signal) => { + if (settled) { + return; + } + + settled = true; + if (code === 0) { + resolve({ + stdout, + stderr, + tokenUsage: parseCodexTokenUsage(`${stderr}\n${stdout}`), + }); + return; + } + + const details = summarizeFailureOutput( + `${stderr}\n${stdout}`, + `Codex exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}`, + ); + reject(new Error(details)); + }); + }); + }; + + const runCodexExecWithRetry = async () => { + let lastError = null; + + for (let attempt = 1; attempt <= CODEX_EXEC_MAX_ATTEMPTS; attempt += 1) { + try { + return await runCodexExecOnce(attempt); + } catch (error) { + lastError = error; + + if (attempt >= CODEX_EXEC_MAX_ATTEMPTS || !isTransientCodexExecFailure(error)) { + throw error; + } + + const outputText = await readFile(outputFile, 'utf8').catch(() => ''); + + if (outputText.trim()) { + reportProgress('Codex ๊ฒฐ๊ณผ ํŒŒ์ผ์ด ๋‚จ์•„ ์žˆ์–ด ๋‚ด๋ถ€ ์ฑ„๋„ ์˜ค๋ฅ˜๋ฅผ ์„ฑ๊ณต์œผ๋กœ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.'); + return; + } + + if (await hasGitChanges()) { + throw error; + } + + await wait(CODEX_EXEC_RETRY_DELAY_MS); + } + } + + throw lastError ?? new Error('Codex ์ž๋™ ์ž‘์—… ์‹คํ–‰์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + }; + + try { + reportProgress(`์ž‘์—… ๋ธŒ๋žœ์น˜ ${item.assignedBranch ?? item.releaseTarget ?? 'release'} ์ƒํƒœ๋ฅผ ์ค€๋น„ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.`); + await ensureWorkingBranch(item); + const headBeforeCodex = await getHeadCommit(); + reportProgress('Codex ์ž๋™ ์ž‘์—…์„ ์‹คํ–‰ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + let codexRunMetadata = { tokenUsage: null }; + try { + codexRunMetadata = (await runCodexExecWithRetry()) ?? codexRunMetadata; + } catch (error) { + const headAfterCodex = await getHeadCommit(); + + if (headAfterCodex && headAfterCodex !== headBeforeCodex) { + const summary = await getHeadCommitSubject(); + const message = error instanceof Error ? error.message : String(error); + return [`RECOVERED_COMMIT: ${headAfterCodex}`, summary, `Original error: ${message}`].join('\n'); + } + + throw error; + } + + reportProgress('Codex ์‘๋‹ต์„ ์ •๋ฆฌํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const message = (await readFile(outputFile, 'utf8')).trim(); + return { + message: message || 'NOOP: ๋ณ„๋„ ์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ๋‚จ๊ธฐ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + tokenUsage: codexRunMetadata.tokenUsage, + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +async function processErrorLogReviewPlan(item) { + const rangeEnd = new Date(); + const rangeStart = new Date(rangeEnd.getTime() - 24 * 60 * 60 * 1000); + + reportProgress('์—๋Ÿฌ ๋กœ๊ทธ ๊ธฐ๋ฐ˜ Plan ๊ฒŒ์‹œํŒ ๋“ฑ๋ก ์š”์ฒญ์„ ๊ฐ์ง€ํ•ด ์ตœ๊ทผ 24์‹œ๊ฐ„ ๋กœ๊ทธ๋ฅผ ์ง‘๊ณ„ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const result = await request('/plan/registrations/error-logs', { + method: 'POST', + body: JSON.stringify({ + rangeStart: rangeStart.toISOString(), + rangeEnd: rangeEnd.toISOString(), + }), + }); + + const createdBoardPosts = result?.createdBoardPosts ?? []; + const skippedBoardPosts = result?.skippedBoardPosts ?? []; + const recentLogCount = Number(result?.recentLogCount ?? 0); + const rawCandidateCount = Number(result?.rawCandidateCount ?? 0); + const candidateCount = Number(result?.candidateCount ?? 0); + + const summaryLines = [ + `์กฐํšŒ ๊ตฌ๊ฐ„: ${rangeStart.toISOString()} ~ ${rangeEnd.toISOString()}`, + `์กฐํšŒ ๋กœ๊ทธ: ${recentLogCount}๊ฑด`, + `์—๋Ÿฌ ํ›„๋ณด ์›๋ณธ: ${rawCandidateCount}๊ฑด`, + `์—๋Ÿฌ ํ›„๋ณด ๋“ฑ๋ก์šฉ ๋ฌถ์Œ: ${candidateCount}๊ฑด`, + `์‹ ๊ทœ ๊ฒŒ์‹œ๊ธ€ ๋“ฑ๋ก: ${createdBoardPosts.length}๊ฑด`, + `์ค‘๋ณต ์ œ์™ธ: ${skippedBoardPosts.length}๊ฑด`, + ]; + + if (createdBoardPosts.length > 0) { + summaryLines.push(''); + summaryLines.push('๋“ฑ๋ก๋œ ๊ฒŒ์‹œ๊ธ€:'); + summaryLines.push(...createdBoardPosts.map((post) => `- ๊ฒŒ์‹œ๊ธ€ #${post.postId} ${post.workId} (${post.count}๊ฑด)`)); + } + + if (skippedBoardPosts.length > 0) { + summaryLines.push(''); + summaryLines.push('์ œ์™ธ๋œ ํ•ญ๋ชฉ:'); + summaryLines.push(...skippedBoardPosts.map((post) => `- ${post.workId}: ${post.reason}`)); + } + + const summary = summaryLines.join('\n'); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '์—๋Ÿฌ๋กœ๊ทธ์ ๊ฒ€', + actionNote: [ + summary, + '', + 'Plan ๊ฒŒ์‹œํŒ ๋“ฑ๋ก ์ „์šฉ ์š”์ฒญ์œผ๋กœ ์ฒ˜๋ฆฌํ–ˆ๊ณ , ์ €์žฅ์†Œ ์†Œ์Šค ์ˆ˜์ • ์ด๋ ฅ์€ ๋‚จ๊ธฐ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + ].join('\n'), + }), + }); + + await request(`/plan/items/${item.id}/actions/complete`, { + method: 'POST', + body: JSON.stringify({ + note: createdBoardPosts.length > 0 ? `์—๋Ÿฌ ๋กœ๊ทธ ๊ธฐ๋ฐ˜ Plan ๊ฒŒ์‹œ๊ธ€ ${createdBoardPosts.length}๊ฑด์„ ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.` : '๋“ฑ๋กํ•  ์‹ ๊ทœ ์—๋Ÿฌ Plan ๊ฒŒ์‹œ๊ธ€์ด ์—†์—ˆ์Šต๋‹ˆ๋‹ค.', + }), + }); + + return { + id: item.id, + outcome: 'error-log-review-complete', + createdBoardPosts, + skippedBoardPosts, + }; +} + +async function processPlan(item) { + reportProgress(`Plan ${item.workId} ์š”์ฒญ ์ฒ˜๋ฆฌ๋ฅผ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.`); + if (shouldProcessAsPlanDocument(item)) { + return processErrorLogReviewPlan(item); + } + + const baselineChangedFiles = localMainMode ? await listChangedFiles() : []; + const baselineChangedPathSet = new Set(normalizeChangedPaths(baselineChangedFiles)); + const codexResult = await runCodexForPlan(item); + const result = codexResult.message; + const tokenUsage = codexResult.tokenUsage; + const tokenUsageLine = tokenUsage ? `ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰: ${tokenUsage}` : null; + const summary = + result.replace(/^DONE:\s*/, '').replace(/^NOOP:\s*/, '').trim() || '์š”์ฒญ ๊ฒ€ํ† ๋ฅผ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + const boardPost = parseBoardPostResult(result); + const recoveredCommit = parseRecoveredCommitResult(result); + + if (isAutomationEnvironmentFailure(summary) || isAutomationEnvironmentFailure(result)) { + throw new Error( + '์ž๋™ํ™” ์‹คํ–‰ ํ™˜๊ฒฝ ๋ฌธ์ œ๋กœ ์‹ค์ œ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ƒ์„ธ: ' + summary, + ); + } + + if (boardPost) { + if (!isBoardDraftOnlyRequest(item.note)) { + throw new Error('๊ฒŒ์‹œํŒ ์ž‘์„ฑ ์ „์šฉ ์š”์ฒญ์ด ์•„๋‹Œ๋ฐ BOARD_POST ๊ฒฐ๊ณผ๊ฐ€ ๋ฐ˜ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + } + + reportProgress('๊ฒŒ์‹œํŒ ์ž‘์„ฑ ์ „์šฉ ์š”์ฒญ์„ Plan ๊ฒŒ์‹œํŒ์— ๋ฏธ์ ‘์ˆ˜ ๊ธ€๋กœ ๋“ฑ๋กํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const created = await request('/board/posts', { + method: 'POST', + body: JSON.stringify(boardPost), + }); + const boardPostId = created?.item?.id ?? null; + const boardSummary = `Plan ๊ฒŒ์‹œํŒ ๋ฏธ์ ‘์ˆ˜ ๊ธ€์„ ์ž‘์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.${boardPostId ? ` ๊ฒŒ์‹œ๊ธ€ #${boardPostId}` : ''}`; + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary: boardSummary, + branchName: item.assignedBranch || item.releaseTarget || 'release', + commitHash: null, + changedFiles: [], + commandLog: [ + `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, + tokenUsageLine, + `POST /api/board/posts${boardPostId ? ` -> #${boardPostId}` : ''}`, + 'BOARD_POST: ์ €์žฅ์†Œ ํŒŒ์ผ ๋ณ€๊ฒฝ ์—†์Œ', + ] + .filter(Boolean) + .join('\n'), + diffText: null, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '๊ฒŒ์‹œํŒ์ž‘์„ฑ', + actionNote: [ + boardSummary, + tokenUsageLine, + `์ œ๋ชฉ: ${boardPost.title}`, + '์ž๋™ํ™” ์ ‘์ˆ˜๋Š” ํ•˜์ง€ ์•Š๊ณ  ๋ฏธ์ ‘์ˆ˜ ์ƒํƒœ๋กœ ๋‚จ๊ฒผ์Šต๋‹ˆ๋‹ค.', + ] + .filter(Boolean) + .join('\n'), + }), + }); + + await request(`/plan/items/${item.id}/actions/complete`, { + method: 'POST', + body: JSON.stringify({ + note: boardSummary, + }), + }); + + return { + id: item.id, + outcome: 'board-post-complete', + boardPostId, + result, + }; + } + + if (recoveredCommit) { + reportProgress('Codex ๋น„์ •์ƒ ์ข…๋ฃŒ ํ›„ ๋‚จ์€ ๋กœ์ปฌ ์ปค๋ฐ‹์„ ๋ณต๊ตฌํ•ด ์›๊ฒฉ ๋ธŒ๋žœ์น˜์™€ Plan ์ด๋ ฅ์— ๊ธฐ๋กํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + await runCommand('git', ['push', '-u', 'origin', item.assignedBranch]); + const previewUrl = buildPreviewUrl(item, recoveredCommit.commitHash); + const changedPaths = await listHeadChangedFiles(); + const [{ stdout: diffText }, sourceFiles] = await Promise.all([ + runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']), + collectSourceFileSnapshots(), + ]); + const recoveredSummary = [ + recoveredCommit.summary, + recoveredCommit.originalError ? `Codex ์ข…๋ฃŒ ์˜ค๋ฅ˜ ๋ณต๊ตฌ: ${recoveredCommit.originalError}` : null, + ] + .filter(Boolean) + .join('\n'); + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary: previewUrl ? `${recoveredSummary}\nPreview: ${previewUrl}` : recoveredSummary, + branchName: item.assignedBranch, + commitHash: recoveredCommit.commitHash.slice(0, 12), + previewUrl, + changedFiles: changedPaths, + commandLog: [ + `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, + tokenUsageLine, + `RECOVERED_COMMIT: ${recoveredCommit.commitHash}`, + `git push -u origin ${item.assignedBranch}`, + 'git show --stat --patch --format=medium HEAD', + ] + .filter(Boolean) + .join('\n'), + diffText: diffText.trim(), + sourceFiles, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '์†Œ์Šค์ž‘์—…๋ณต๊ตฌ', + actionNote: + [ + recoveredSummary, + tokenUsageLine, + previewUrl ? `Preview: ${previewUrl}` : null, + changedPaths.length > 0 ? `๋ณ€๊ฒฝ ํŒŒ์ผ:\n${changedPaths.join('\n')}` : null, + `์ปค๋ฐ‹: ${recoveredCommit.commitHash.slice(0, 12)}`, + `๋ธŒ๋žœ์น˜: ${item.assignedBranch}`, + ] + .filter(Boolean) + .join('\n\n'), + }), + }); + + return { + id: item.id, + outcome: 'development-complete', + result, + }; + } + + if (isBoardDraftOnlyRequest(item.note)) { + throw new Error('๊ฒŒ์‹œํŒ ์ž‘์„ฑ ์ „์šฉ ์š”์ฒญ์ธ๋ฐ ์ž๋™ํ™”๊ฐ€ BOARD_POST ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ €์žฅ์†Œ ํŒŒ์ผ ๋ณ€๊ฒฝ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.'); + } + + const changed = await hasGitChanges(); + + if (result.startsWith('NOOP:')) { + reportProgress('์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์ด ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅํ•œ ์š”์ฒญ์ธ์ง€ ๊ฒ€์ฆํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + if (!shouldSkipSourceChangeRequirement(item) && requiresSourceChange(item.note)) { + throw new Error( + `์†Œ์Šค ๋ณ€๊ฒฝ์ด ํ•„์š”ํ•œ ์š”์ฒญ์ธ๋ฐ ์ž๋™ํ™”๊ฐ€ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์‘๋‹ต: ${summary || 'NOOP'}` + ); + } + + reportProgress('NOOP ๊ฒฐ๊ณผ๋ฅผ Plan ์ด๋ ฅ์— ๊ธฐ๋กํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary: summary || '์š”์ฒญ์„ ๊ฒ€ํ† ํ•˜๊ณ  ๋ณ„๋„ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์ด ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + branchName: item.assignedBranch || item.releaseTarget || 'release', + commitHash: null, + changedFiles: [], + commandLog: [ + `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, + tokenUsageLine, + 'NOOP: ๋ณ„๋„ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์Œ', + ] + .filter(Boolean) + .join('\n'), + diffText: null, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '์ž๋™์™„๋ฃŒ๋ฉ”๋ชจ', + actionNote: [summary || '๋ณ„๋„ ์ฝ”๋“œ ๋ณ€๊ฒฝ ์—†์ด ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', tokenUsageLine].filter(Boolean).join('\n'), + }), + }); + + await request(`/plan/items/${item.id}/actions/complete`, { + method: 'POST', + body: JSON.stringify({ + note: summary || '๋ณ„๋„ ์ž‘์—…์ด ์—†์–ด ์™„๋ฃŒ ์ฒ˜๋ฆฌํ–ˆ์Šต๋‹ˆ๋‹ค.', + }), + }); + return { + id: item.id, + outcome: 'noop-complete', + result, + }; + } + + reportProgress('๋ณ€๊ฒฝ ํŒŒ์ผ๊ณผ ์š”์ฒญ ๊ฒฝ๋กœ ์ผ์น˜ ์—ฌ๋ถ€๋ฅผ ๊ฒ€์ฆํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + if (!changed) { + throw new Error( + `์ž๋™ ์ž‘์—… ๊ฒฐ๊ณผ์— ์‹ค์ œ ๋ณ€๊ฒฝ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. ์‘๋‹ต: ${summary || '์ž‘์—… ๊ฒฐ๊ณผ ์š”์•ฝ์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'}`, + ); + } + + const changedFiles = await listChangedFiles(); + const changedPaths = normalizeChangedPaths(changedFiles); + const effectiveChangedPaths = localMainMode + ? changedPaths.filter((changedPath) => !baselineChangedPathSet.has(changedPath)) + : changedPaths; + const requestedPaths = extractRequestedPaths(item.note); + + if (requestedPaths.length > 0) { + const matched = requestedPaths.some((requestedPath) => + effectiveChangedPaths.some((changedPath) => changedPath === requestedPath || changedPath.endsWith('/' + requestedPath)), + ); + + if (!matched) { + throw new Error( + '์š”์ฒญํ•œ ํŒŒ์ผ ๊ฒฝ๋กœ๊ฐ€ ์‹ค์ œ ๋ณ€๊ฒฝ ๋ชฉ๋ก์— ์—†์Šต๋‹ˆ๋‹ค. ์š”์ฒญ ๊ฒฝ๋กœ: ' + + requestedPaths.join(', ') + + ' / ์‹ค์ œ ๋ณ€๊ฒฝ: ' + + (effectiveChangedPaths.join(', ') || '์—†์Œ'), + ); + } + } + + if (localMainMode) { + reportProgress('๋กœ์ปฌ main ์ง์ ‘ ์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ์ด๋ ฅ๊ณผ ์กฐ์น˜ ๋ฉ”๋ชจ๋กœ ์ €์žฅํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const sourceFiles = await collectWorkingTreeSourceSnapshots(effectiveChangedPaths); + const diffText = + effectiveChangedPaths.length > 0 + ? (await runCommand('git', ['diff', '--', ...effectiveChangedPaths]).then(({ stdout }) => stdout).catch(() => '')) + : ''; + const commandLog = [ + `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, + tokenUsageLine, + 'local main mode: commit/push skipped', + effectiveChangedPaths.length > 0 ? `git diff -- ${effectiveChangedPaths.join(' ')}` : 'git diff -- ๋ณ€๊ฒฝ ํŒŒ์ผ ์—†์Œ', + ] + .filter(Boolean) + .join('\n'); + + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary: summary || 'Codex ๋กœ์ปฌ main ์†Œ์Šค ์ž‘์—… ๊ฒฐ๊ณผ', + branchName: item.assignedBranch || 'main', + commitHash: null, + previewUrl: null, + changedFiles: effectiveChangedPaths, + commandLog, + diffText: diffText.trim() || null, + sourceFiles, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '์†Œ์Šค์ž‘์—…', + actionNote: + [ + summary || 'Codex ๋กœ์ปฌ main ์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.', + tokenUsageLine, + effectiveChangedPaths.length > 0 ? `๋ณ€๊ฒฝ ํŒŒ์ผ:\n${effectiveChangedPaths.join('\n')}` : null, + `๋ธŒ๋žœ์น˜: ${item.assignedBranch || 'main'} (๋กœ์ปฌ ์ง์ ‘ ์ž‘์—…)`, + '์ปค๋ฐ‹๊ณผ ์›๊ฒฉ ํ‘ธ์‹œ๋Š” ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + ] + .filter(Boolean) + .join('\n\n'), + }), + }); + + if (!skipWorkComplete) { + await request(`/plan/items/${item.id}/actions/complete-development`, { + method: 'POST', + body: JSON.stringify({}), + }); + } + + reportProgress('Plan ์ƒํƒœ๋ฅผ ๋กœ์ปฌ main ์ž‘์—… ์™„๋ฃŒ๋กœ ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค.'); + return { + id: item.id, + outcome: 'development-complete', + result, + }; + } + + reportProgress('๋ณ€๊ฒฝ ๋‚ด์šฉ์„ ์ปค๋ฐ‹ํ•˜๊ณ  ์›๊ฒฉ ๋ธŒ๋žœ์น˜๋กœ ํ‘ธ์‹œํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const { commitHash, commitMessage } = await commitAndPushBranch(item, summary); + const previewUrl = buildPreviewUrl(item, commitHash); + const [{ stdout: diffText }, sourceFiles] = await Promise.all([ + runCommand('git', ['show', '--stat', '--patch', '--format=medium', 'HEAD']), + collectSourceFileSnapshots(), + ]); + const commandLog = [ + `${codexBin} exec --dangerously-bypass-approvals-and-sandbox -C ${repoPath} -o ""`, + tokenUsageLine, + 'git add -A', + `git commit -m "${commitMessage}"`, + `git push -u origin ${item.assignedBranch}`, + 'git show --stat --patch --format=medium HEAD', + ] + .filter(Boolean) + .join('\n'); + + reportProgress('์†Œ์Šค ์ž‘์—… ์ด๋ ฅ๊ณผ ์กฐ์น˜ ๋ฉ”๋ชจ๋ฅผ ์ €์žฅํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + await request(`/plan/items/${item.id}/source-works`, { + method: 'POST', + body: JSON.stringify({ + summary: previewUrl ? `${summary || 'Codex ์†Œ์Šค ์ž‘์—… ๊ฒฐ๊ณผ'}\nPreview: ${previewUrl}` : summary || 'Codex ์†Œ์Šค ์ž‘์—… ๊ฒฐ๊ณผ', + branchName: item.assignedBranch, + commitHash, + previewUrl, + changedFiles: changedPaths, + commandLog, + diffText: diffText.trim(), + sourceFiles, + }), + }); + + await request(`/plan/items/${item.id}/actions/note`, { + method: 'POST', + body: JSON.stringify({ + actionType: '์†Œ์Šค์ž‘์—…', + actionNote: + [ + summary || 'Codex ์ž‘์—… ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.', + tokenUsageLine, + previewUrl ? `Preview: ${previewUrl}` : null, + changedFiles.length > 0 ? `๋ณ€๊ฒฝ ํŒŒ์ผ:\n${changedFiles.join('\n')}` : null, + commitHash ? `์ปค๋ฐ‹: ${commitHash}` : null, + `๋ธŒ๋žœ์น˜: ${item.assignedBranch}`, + ] + .filter(Boolean) + .join('\n\n'), + }), + }); + + if (!skipWorkComplete) { + await request(`/plan/items/${item.id}/actions/complete-development`, { + method: 'POST', + body: JSON.stringify({}), + }); + } + + reportProgress('Plan ์ƒํƒœ๋ฅผ ๊ฐœ๋ฐœ ์™„๋ฃŒ๋กœ ์ „ํ™˜ํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + return { + id: item.id, + outcome: 'development-complete', + result, + }; +} + +async function main() { + const isExecutableTarget = (item) => + item && + item.status === '์ž‘์—…์ค‘' && + Boolean(item.assignedBranch) && + ['๋ธŒ๋žœ์น˜์ค€๋น„', '์ž๋™์ž‘์—…์ค‘'].includes(item.workerStatus); + + let targets = []; + + if (planItemId) { + const data = await request(`/plan/items/${planItemId}`); + const item = data.item; + targets = isExecutableTarget(item) ? [item] : []; + } else { + const data = await request('/plan/items'); + targets = (data.items ?? []).filter((item) => isExecutableTarget(item)); + } + + if (targets.length === 0) { + console.log('์ฒ˜๋ฆฌํ•  Plan ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.'); + return; + } + + for (const item of targets) { + await ensureCleanWorkingTree(); + const processed = await processPlan(item); + console.log(JSON.stringify(processed, null, 2)); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs new file mode 100644 index 0000000..82578e8 --- /dev/null +++ b/scripts/run-server-command-runner.mjs @@ -0,0 +1,1045 @@ +import { createServer } from 'node:http'; +import { execFile, spawn } from 'node:child_process'; +import { access, cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, '..'); +const host = process.env.SERVER_COMMAND_RUNNER_HOST?.trim() || '0.0.0.0'; +const port = Number(process.env.SERVER_COMMAND_RUNNER_PORT?.trim() || '3211'); +const accessToken = process.env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() || 'local-server-command-runner'; +const runnerLogFile = process.env.SERVER_COMMAND_RUNNER_LOG_FILE?.trim() || '/tmp/server-command-runner.log'; +const heartbeatFile = + process.env.SERVER_COMMAND_RUNNER_HEARTBEAT_FILE?.trim() || path.join(projectRoot, '.server-command-runner-heartbeat.json'); +const startedAt = new Date().toISOString(); +const runnerLogMaxBytes = Math.max(256 * 1024, Number(process.env.SERVER_COMMAND_RUNNER_LOG_MAX_BYTES?.trim() || `${5 * 1024 * 1024}`)); +const runnerLogTrimToBytes = Math.max( + 128 * 1024, + Math.min( + runnerLogMaxBytes, + Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_TO_BYTES?.trim() || `${2 * 1024 * 1024}`), + ), +); +const runnerLogTrimIntervalMs = Math.max( + 15_000, + Number(process.env.SERVER_COMMAND_RUNNER_LOG_TRIM_INTERVAL_MS?.trim() || '60000'), +); +const cpuWatchdogEnabled = process.env.SERVER_COMMAND_CPU_WATCHDOG_ENABLED?.trim() !== 'false'; +const cpuWatchdogIntervalMs = Math.max(15_000, Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_INTERVAL_MS?.trim() || '60000')); +const cpuWatchdogThresholdPercent = Math.max( + 10, + Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_THRESHOLD_PERCENT?.trim() || '120'), +); +const cpuWatchdogConsecutiveLimit = Math.max( + 2, + Math.round(Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_CONSECUTIVE_LIMIT?.trim() || '8')), +); +const cpuWatchdogCooldownMs = Math.max( + 60_000, + Number(process.env.SERVER_COMMAND_CPU_WATCHDOG_COOLDOWN_MS?.trim() || '1200000'), +); +const STREAM_CAPTURE_LIMIT = 256 * 1024; +const CODEX_HOME_RUNTIME_PATHS = [ + 'auth.json', + 'config.toml', + 'rules', + 'skills', + 'vendor_imports', + 'models_cache.json', + 'version.json', +]; +const activeCodexExecutions = new Map(); + +const commandDefinitions = { + test: { + label: 'TEST', + scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-test.sh'), + workingDirectory: projectRoot, + env: { + MAIN_PROJECT_ROOT: projectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_TEST_SERVICE?.trim() || 'app', + SERVER_COMMAND_CONTAINER_NAME: process.env.SERVER_COMMAND_TEST_CONTAINER_NAME?.trim() || 'ai-code-app-app-1', + }, + restartStrategy: 'deferred', + }, + rel: { + label: 'REL', + scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-rel.sh'), + workingDirectory: projectRoot, + env: { + MAIN_PROJECT_ROOT: projectRoot, + SERVER_COMMAND_COMPOSE_FILE: path.join(projectRoot, 'docker-compose.yml'), + SERVER_COMMAND_SERVICE: process.env.SERVER_COMMAND_REL_SERVICE?.trim() || 'release-app', + }, + restartStrategy: 'deferred', + }, + 'work-server': { + label: 'WORK-SERVER', + scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-work-server.sh'), + workingDirectory: projectRoot, + env: { + REPO_ROOT: projectRoot, + }, + restartStrategy: 'deferred', + }, + 'command-runner': { + label: 'COMMAND-RUNNER', + scriptPath: path.join(projectRoot, 'etc', 'commands', 'server-command', 'restart-server-command-runner.sh'), + workingDirectory: projectRoot, + env: { + PROJECT_ROOT: projectRoot, + SERVER_COMMAND_RUNNER_NODE_BIN: + process.env.SERVER_COMMAND_RUNNER_NODE_BIN?.trim() || 'node', + SERVER_COMMAND_RUNNER_HOST: process.env.SERVER_COMMAND_RUNNER_HOST?.trim() || '127.0.0.1', + SERVER_COMMAND_RUNNER_PORT: process.env.SERVER_COMMAND_RUNNER_PORT?.trim() || '3211', + SERVER_COMMAND_RUNNER_ACCESS_TOKEN: process.env.SERVER_COMMAND_RUNNER_ACCESS_TOKEN?.trim() || accessToken, + }, + restartStrategy: 'deferred', + }, +}; + +function normalizeRepoPathCandidate(value) { + const normalized = String(value ?? '').trim(); + return normalized ? path.resolve(normalized) : ''; +} + +function translateWorkspacePathToHost(inputPath) { + const normalizedInput = normalizeRepoPathCandidate(inputPath); + + if (!normalizedInput) { + return normalizedInput; + } + + const replacements = [ + ['/workspace/main-project', projectRoot], + ['/workspace/auto_codex/repo', projectRoot], + ]; + + for (const [workspacePrefix, hostPrefix] of replacements) { + if (normalizedInput === workspacePrefix) { + return hostPrefix; + } + + if (normalizedInput.startsWith(`${workspacePrefix}${path.sep}`)) { + return path.join(hostPrefix, normalizedInput.slice(workspacePrefix.length + 1)); + } + } + + return normalizedInput; +} + +const cpuWatchdogTargets = [ + { + name: 'test-app', + containerName: 'ai-code-app-app-1', + restartMode: 'command', + restartKey: 'test', + }, + { + name: 'release-app', + containerName: 'ai-code-app-release', + restartMode: 'command', + restartKey: 'rel', + }, + { + name: 'prod-app', + containerName: 'ai-code-app-prod', + restartMode: 'docker', + }, + { + name: 'work-server', + containerName: 'work-server', + restartMode: 'command', + restartKey: 'work-server', + }, +]; + +const cpuWatchdogState = new Map( + cpuWatchdogTargets.map((target) => [ + target.containerName, + { + lastCpuPercent: null, + breachCount: 0, + lastSampleAt: null, + lastRestartAt: null, + lastRestartReason: null, + }, + ]), +); +let cpuWatchdogBusy = false; + +function trimOutput(value, maxLength = 400) { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized; +} + +function sendJson(response, statusCode, payload) { + response.writeHead(statusCode, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }); + response.end(JSON.stringify(payload)); +} + +async function trimRunnerLogIfNeeded() { + try { + const logStat = await stat(runnerLogFile); + + if (!logStat.isFile() || logStat.size <= runnerLogMaxBytes) { + return; + } + + const content = await readFile(runnerLogFile, 'utf8'); + const trimmedContent = content.slice(-runnerLogTrimToBytes); + const boundaryIndex = trimmedContent.indexOf('\n'); + const nextContent = boundaryIndex >= 0 ? trimmedContent.slice(boundaryIndex + 1) : trimmedContent; + + await writeFile(runnerLogFile, `[log-trimmed ${new Date().toISOString()}]\n${nextContent}`, 'utf8'); + } catch { + // ignore log trim failures so the runner itself keeps serving requests + } +} + +async function writeHeartbeat() { + await mkdir(path.dirname(heartbeatFile), { recursive: true }); + await writeFile( + heartbeatFile, + JSON.stringify( + { + ok: true, + service: 'server-command-runner', + pid: process.pid, + host, + port, + cwd: projectRoot, + startedAt, + updatedAt: new Date().toISOString(), + cpuWatchdog: { + enabled: cpuWatchdogEnabled, + intervalMs: cpuWatchdogIntervalMs, + thresholdPercent: cpuWatchdogThresholdPercent, + consecutiveLimit: cpuWatchdogConsecutiveLimit, + cooldownMs: cpuWatchdogCooldownMs, + targets: cpuWatchdogTargets.map((target) => { + const state = cpuWatchdogState.get(target.containerName); + return { + name: target.name, + containerName: target.containerName, + restartMode: target.restartMode, + lastCpuPercent: state?.lastCpuPercent ?? null, + breachCount: state?.breachCount ?? 0, + lastSampleAt: state?.lastSampleAt ?? null, + lastRestartAt: state?.lastRestartAt ?? null, + lastRestartReason: state?.lastRestartReason ?? null, + }; + }), + }, + }, + null, + 2, + ), + 'utf8', + ); +} + +function parseCpuPercentage(value) { + const numeric = Number(String(value ?? '').replace('%', '').trim()); + return Number.isFinite(numeric) ? numeric : null; +} + +async function restartContainerByDocker(containerName) { + await execFileAsync('docker', ['restart', containerName], { + cwd: projectRoot, + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); +} + +async function sampleCpuWatchdog() { + if (!cpuWatchdogEnabled || cpuWatchdogBusy || cpuWatchdogTargets.length === 0) { + return; + } + + cpuWatchdogBusy = true; + + try { + const { stdout } = await execFileAsync( + 'docker', + [ + 'stats', + '--no-stream', + '--format', + '{{json .}}', + ...cpuWatchdogTargets.map((target) => target.containerName), + ], + { + cwd: projectRoot, + timeout: 15_000, + maxBuffer: 1024 * 1024, + }, + ); + const now = new Date().toISOString(); + const sampledContainers = new Set(); + + for (const line of stdout.split('\n')) { + const trimmedLine = line.trim(); + + if (!trimmedLine) { + continue; + } + + let parsed; + + try { + parsed = JSON.parse(trimmedLine); + } catch { + continue; + } + + const containerName = String(parsed.Name ?? '').trim(); + const cpuPercent = parseCpuPercentage(parsed.CPUPerc); + const target = cpuWatchdogTargets.find((entry) => entry.containerName === containerName); + const state = cpuWatchdogState.get(containerName); + + if (!target || !state) { + continue; + } + + sampledContainers.add(containerName); + state.lastCpuPercent = cpuPercent; + state.lastSampleAt = now; + state.breachCount = cpuPercent != null && cpuPercent >= cpuWatchdogThresholdPercent ? state.breachCount + 1 : 0; + + const cooldownPassed = + !state.lastRestartAt || Date.now() - new Date(state.lastRestartAt).getTime() >= cpuWatchdogCooldownMs; + + if (state.breachCount < cpuWatchdogConsecutiveLimit || !cooldownPassed) { + continue; + } + + const restartReason = `cpu ${cpuPercent?.toFixed(1) ?? '?'}% sustained for ${ + state.breachCount + } samples`; + + process.stdout.write( + `[cpu-watchdog] restarting ${target.containerName} because ${restartReason} (threshold ${cpuWatchdogThresholdPercent}%)\n`, + ); + + if (target.restartMode === 'command' && target.restartKey) { + await runRestartCommand(target.restartKey); + } else { + await restartContainerByDocker(target.containerName); + } + + state.breachCount = 0; + state.lastRestartAt = new Date().toISOString(); + state.lastRestartReason = restartReason; + await writeHeartbeat().catch(() => { + // noop + }); + } + + for (const target of cpuWatchdogTargets) { + if (sampledContainers.has(target.containerName)) { + continue; + } + + const state = cpuWatchdogState.get(target.containerName); + + if (!state) { + continue; + } + + state.lastCpuPercent = null; + state.lastSampleAt = now; + state.breachCount = 0; + } + } catch (error) { + process.stdout.write( + `[cpu-watchdog] sample failed: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } finally { + cpuWatchdogBusy = false; + } +} + +void trimRunnerLogIfNeeded(); +setInterval(() => { + void trimRunnerLogIfNeeded(); +}, runnerLogTrimIntervalMs).unref(); + +function sendJsonLine(response, payload) { + response.write(`${JSON.stringify(payload)}\n`); +} + +function readRequestBody(request, maxBytes = 4 * 1024 * 1024) { + return new Promise((resolve, reject) => { + let total = 0; + const chunks = []; + + request.on('data', (chunk) => { + total += chunk.length; + + if (total > maxBytes) { + reject(new Error('์š”์ฒญ ๋ณธ๋ฌธ์ด ๋„ˆ๋ฌด ํฝ๋‹ˆ๋‹ค.')); + request.destroy(); + return; + } + + chunks.push(chunk); + }); + + request.on('end', () => { + resolve(Buffer.concat(chunks).toString('utf8')); + }); + + request.on('error', reject); + }); +} + +function summarizeCodexOutput(output) { + const normalized = String(output ?? '').trim(); + + if (!normalized) { + return 'Codex ์‹คํ–‰ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.'; + } + + return normalized + .split('\n') + .map((line) => line.trimEnd()) + .filter(Boolean) + .slice(-12) + .join('\n'); +} + +function summarizeCommand(command, limit = 180) { + const normalized = String(command ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > limit ? `${normalized.slice(0, limit - 1).trimEnd()}...` : normalized; +} + +function summarizeCommandOutput(output, maxLines = 3, maxLength = 220) { + const lines = String(output ?? '') + .replace(/\r/g, '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length === 0) { + return ''; + } + + const joined = lines.slice(0, maxLines).join(' / '); + return joined.length > maxLength ? `${joined.slice(0, maxLength - 1).trimEnd()}...` : joined; +} + +function inferCommandReason(command) { + const normalized = command.toLowerCase(); + + if (normalized.includes('rg ') || normalized.includes('ripgrep')) { + return '๊ด€๋ จ ํŒŒ์ผ์ด๋‚˜ ํ…์ŠคํŠธ๋ฅผ ๋น ๋ฅด๊ฒŒ ์ฐพ๊ธฐ ์œ„ํ•ด ๊ฒ€์ƒ‰ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('sed -n') || normalized.includes('cat ') || normalized.includes('less ')) { + return 'ํ•ด๋‹น ํŒŒ์ผ ๋‚ด์šฉ์„ ์ง์ ‘ ์ฝ์–ด ๋ฌธ๋งฅ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('ls ') || normalized === 'ls' || normalized.includes('rg --files')) { + return '๋””๋ ‰ํ„ฐ๋ฆฌ ๊ตฌ์กฐ์™€ ๋Œ€์ƒ ํŒŒ์ผ ์œ„์น˜๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('git status')) { + return '์ž‘์—… ํŠธ๋ฆฌ ์ƒํƒœ์™€ ๋ณ€๊ฒฝ ๋ฒ”์œ„๋ฅผ ์ ๊ฒ€ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('tsc ') || normalized.includes('npm test') || normalized.includes('node --test')) { + return '์ˆ˜์ • ํ›„ ํƒ€์ž…์ด๋‚˜ ๋™์ž‘ ๊ฒ€์ฆ์„ ์ง„ํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + if (normalized.includes('curl ') || normalized.includes('fetch')) { + return '์‹ค์ œ ์‘๋‹ต์ด๋‚˜ ์—ฐ๊ฒฐ ์ƒํƒœ๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; + } + + return 'ํ˜„์žฌ ์š”์ฒญ์„ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•œ ๋‹ค์Œ ๋‹จ๊ณ„๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.'; +} + +function isIgnorableCodexDiagnosticLine(line) { + const normalized = String(line ?? '').trim(); + + if (!normalized) { + return true; + } + + return ( + /^\d{4}-\d{2}-\d{2}T\S+\s+WARN\b/.test(normalized) || + normalized.includes('ignoring interface.defaultPrompt') || + normalized.includes('failed to open state db') || + normalized.includes('state db discrepancy') || + normalized.includes('Failed to kill MCP process group') || + normalized.includes('Failed to delete shell snapshot') || + normalized.includes('failed to unwatch ') + ); +} + +function collectCodexTextFragments(value) { + if (typeof value === 'string') { + const normalized = value.trim(); + return normalized ? [normalized] : []; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => collectCodexTextFragments(item)); + } + + if (!value || typeof value !== 'object') { + return []; + } + + const record = value; + const directTextKeys = ['text', 'delta', 'output_text', 'content', 'message']; + + for (const key of directTextKeys) { + const fragments = collectCodexTextFragments(record[key]); + + if (fragments.length > 0) { + return fragments; + } + } + + if (typeof record.type === 'string' && record.type.includes('output_text')) { + const fragments = collectCodexTextFragments(record.text ?? record.delta); + + if (fragments.length > 0) { + return fragments; + } + } + + return Object.values(record).flatMap((item) => collectCodexTextFragments(item)); +} + +function extractCodexStreamText(parsed) { + const type = typeof parsed.type === 'string' ? parsed.type : ''; + + if (type === 'item.completed') { + return { + completedText: collectCodexTextFragments(parsed.item).join(''), + deltaText: '', + }; + } + + if (type === 'item.delta' || type === 'response.output_text.delta') { + return { + completedText: '', + deltaText: + type === 'response.output_text.delta' + ? collectCodexTextFragments(parsed.delta ?? parsed.text).join('') + : collectCodexTextFragments(parsed.delta).join(''), + }; + } + + if (type === 'response.completed') { + return { + completedText: collectCodexTextFragments(parsed.response).join(''), + deltaText: '', + }; + } + + return { + completedText: '', + deltaText: '', + }; +} + +function extractCodexActivityLog(parsed) { + const type = typeof parsed.type === 'string' ? parsed.type : ''; + const item = parsed.item && typeof parsed.item === 'object' ? parsed.item : null; + const itemType = typeof item?.type === 'string' ? item.type : ''; + + if (!item || itemType !== 'command_execution') { + return ''; + } + + const command = summarizeCommand(typeof item.command === 'string' ? item.command : ''); + + if (!command) { + return ''; + } + + const reason = inferCommandReason(command); + + if (type === 'item.started') { + return `# ์ด์œ : ${reason}\n$ ${command}`; + } + + if (type === 'item.completed') { + const exitCode = typeof item.exit_code === 'number' && Number.isFinite(item.exit_code) ? Math.round(item.exit_code) : null; + const outputSummary = summarizeCommandOutput(typeof item.aggregated_output === 'string' ? item.aggregated_output : ''); + const statusLabel = exitCode === null ? '# ๊ฒฐ๊ณผ: ์™„๋ฃŒ' : exitCode === 0 ? '# ๊ฒฐ๊ณผ: ์™„๋ฃŒ(0)' : `# ๊ฒฐ๊ณผ: ์ข…๋ฃŒ(${exitCode})`; + + return outputSummary ? `${statusLabel}\n# ์ถœ๋ ฅ: ${outputSummary}` : statusLabel; + } + + return ''; +} + +async function prepareWritableCodexHome(tempDir) { + const writableCodexHome = path.join(tempDir, '.codex'); + const sourceCodexHome = + process.env.CODEX_HOME_TEMPLATE?.trim() || + process.env.CODEX_HOME?.trim() || + path.join(process.env.HOME ?? '/root', '.codex'); + + await mkdir(writableCodexHome, { recursive: true }); + + for (const relativePath of CODEX_HOME_RUNTIME_PATHS) { + const sourcePath = path.join(sourceCodexHome, relativePath); + const targetPath = path.join(writableCodexHome, relativePath); + + try { + await access(sourcePath); + } catch { + continue; + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await cp(sourcePath, targetPath, { recursive: true, force: true }); + } + + return writableCodexHome; +} + +async function validateCodexExecutionRuntime(repoPath, codexBin) { + const issues = []; + + try { + const repoStat = await stat(repoPath); + + if (!repoStat.isDirectory()) { + issues.push(`repoPath ๊ฒฝ๋กœ๊ฐ€ ๋””๋ ‰ํ„ฐ๋ฆฌ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค: ${repoPath}`); + } + } catch { + issues.push(`repoPath ๊ฒฝ๋กœ๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${repoPath}`); + } + + const trimmedCodexBin = String(codexBin ?? '').trim(); + const pathCandidates = + trimmedCodexBin && !trimmedCodexBin.includes(path.sep) + ? (process.env.PATH ?? '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => path.join(entry, trimmedCodexBin)) + : [trimmedCodexBin]; + + let codexResolved = false; + + for (const candidate of pathCandidates) { + try { + await access(candidate); + codexResolved = true; + break; + } catch { + // try next candidate + } + } + + if (!codexResolved) { + issues.push(`Codex ์‹คํ–‰ ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค: ${codexBin}`); + } + + if (issues.length > 0) { + throw new Error(issues.join(' / ')); + } +} + +async function runCodexLiveExecution(payload, response) { + const requestId = String(payload?.requestId ?? '').trim(); + const sessionId = String(payload?.sessionId ?? '').trim(); + const repoPath = translateWorkspacePathToHost(String(payload?.repoPath ?? '').trim() || projectRoot); + const prompt = String(payload?.prompt ?? ''); + const resourceDir = translateWorkspacePathToHost( + String(payload?.resourceDir ?? path.join(repoPath, 'public', '.codex_chat', sessionId, 'resource')), + ); + const uploadDir = translateWorkspacePathToHost( + String(payload?.uploadDir ?? path.join(resourceDir, 'uploads')), + ); + const codexBin = process.env.SERVER_COMMAND_RUNNER_CODEX_BIN?.trim() || process.env.PLAN_CODEX_BIN?.trim() || 'codex'; + + if (!requestId || !sessionId || !prompt.trim()) { + sendJson(response, 400, { + message: 'requestId, sessionId, prompt๋Š” ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.', + }); + return; + } + + await validateCodexExecutionRuntime(repoPath, codexBin); + await mkdir(resourceDir, { recursive: true }); + await mkdir(uploadDir, { recursive: true }); + + const tempDir = await mkdtemp(path.join(tmpdir(), 'command-runner-codex-')); + const writableCodexHome = await prepareWritableCodexHome(tempDir); + let stdoutTail = ''; + let stderrTail = ''; + let jsonLineBuffer = ''; + let completedText = ''; + let responseClosed = false; + + response.writeHead(200, { + 'content-type': 'application/x-ndjson; charset=utf-8', + 'cache-control': 'no-store', + }); + + const child = spawn( + codexBin, + ['exec', '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'], + { + cwd: repoPath, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + CODEX_HOME: writableCodexHome, + CODEX_LIVE_CHAT_SESSION_ID: sessionId, + CODEX_LIVE_CHAT_RESOURCE_DIR: resourceDir, + CODEX_LIVE_CHAT_UPLOAD_DIR: uploadDir, + }, + }, + ); + + activeCodexExecutions.set(requestId, { + child, + tempDir, + }); + sendJsonLine(response, { + type: 'started', + pid: child.pid ?? null, + }); + + const cleanup = async () => { + activeCodexExecutions.delete(requestId); + await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); + }; + + const handleCodexJsonLine = (line) => { + let parsed; + + try { + parsed = JSON.parse(line); + } catch { + return false; + } + + const activityLog = extractCodexActivityLog(parsed); + + if (activityLog) { + sendJsonLine(response, { + type: 'activity', + line: activityLog, + }); + } + + const { completedText: nextCompletedText, deltaText } = extractCodexStreamText(parsed); + + if (nextCompletedText) { + completedText = nextCompletedText; + sendJsonLine(response, { + type: 'completed', + text: nextCompletedText, + }); + return true; + } + + if (deltaText) { + sendJsonLine(response, { + type: 'delta', + text: deltaText, + }); + return true; + } + + return false; + }; + + response.on('close', () => { + responseClosed = true; + }); + + child.stdout?.on('data', (chunk) => { + const text = String(chunk); + stdoutTail = (stdoutTail + text).slice(-STREAM_CAPTURE_LIMIT); + jsonLineBuffer += text; + const lines = jsonLineBuffer.split('\n'); + jsonLineBuffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + + if (!line) { + continue; + } + + if (handleCodexJsonLine(line)) { + continue; + } + + if (!line.startsWith('{') && !isIgnorableCodexDiagnosticLine(line)) { + sendJsonLine(response, { + type: 'stdout', + line, + }); + } + } + }); + + child.stderr?.on('data', (chunk) => { + const text = String(chunk); + stderrTail = (stderrTail + text).slice(-STREAM_CAPTURE_LIMIT); + text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + if (isIgnorableCodexDiagnosticLine(line)) { + return; + } + + sendJsonLine(response, { + type: 'stderr', + line, + }); + }); + }); + + child.on('error', async (error) => { + if (!responseClosed) { + sendJsonLine(response, { + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); + response.end(); + } + + await cleanup(); + }); + + child.on('close', async (code) => { + const trailingLine = jsonLineBuffer.trim(); + if (trailingLine) { + handleCodexJsonLine(trailingLine); + } + + if (!responseClosed) { + if (code !== 0) { + sendJsonLine(response, { + type: 'error', + message: summarizeCodexOutput(`${stderrTail}\n${completedText}\n${stdoutTail}`), + }); + } + + response.end(); + } + + await cleanup(); + }); + + child.stdin?.end(prompt); +} + +function isAuthorized(request) { + const token = String(request.headers['x-access-token'] ?? '').trim(); + return token.length > 0 && token === accessToken; +} + +async function runRestartCommand(key) { + const definition = commandDefinitions[key]; + if (!definition) { + return { + statusCode: 404, + payload: { + message: '์ง€์›ํ•˜์ง€ ์•Š๋Š” ์„œ๋ฒ„์ž…๋‹ˆ๋‹ค.', + }, + }; + } + + if (definition.restartStrategy === 'deferred') { + await new Promise((resolve, reject) => { + const child = spawn( + 'sh', + ['-c', `sleep 1 && exec sh "${definition.scriptPath}" >/tmp/${key}-restart.log 2>&1`], + { + cwd: definition.workingDirectory, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + ...definition.env, + }, + }, + ); + + child.once('error', reject); + child.once('spawn', () => { + child.unref(); + resolve(); + }); + }); + + return { + statusCode: 202, + payload: { + ok: true, + restartState: 'accepted', + commandOutput: `${definition.label} ์žฌ๊ธฐ๋™ ์š”์ฒญ์„ ์ ‘์ˆ˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.`, + }, + }; + } + + try { + const commandResult = await execFileAsync('sh', [definition.scriptPath], { + cwd: definition.workingDirectory, + timeout: 30000, + maxBuffer: 1024 * 1024, + env: { + ...process.env, + ...definition.env, + }, + }); + + return { + statusCode: 200, + payload: { + ok: true, + restartState: 'completed', + commandOutput: trimOutput([commandResult.stdout, commandResult.stderr].filter(Boolean).join('\n')), + }, + }; + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + const detail = trimOutput( + [ + failure instanceof Error && 'stderr' in failure ? String(failure.stderr ?? '') : '', + failure instanceof Error && 'stdout' in failure ? String(failure.stdout ?? '') : '', + failure.message, + ] + .filter(Boolean) + .join('\n'), + ); + const code = + failure instanceof Error && 'code' in failure && (typeof failure.code === 'number' || typeof failure.code === 'string') + ? String(failure.code) + : null; + + return { + statusCode: 500, + payload: { + message: `${definition.label} ์žฌ๊ธฐ๋™์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.${code ? ` exit:${code}` : ''}${detail ? ` ${detail}` : ''}`, + }, + }; + } +} + +const server = createServer(async (request, response) => { + const requestUrl = new URL(request.url || '/', `http://${request.headers.host || `${host}:${port}`}`); + + if (request.method === 'GET' && requestUrl.pathname === '/health') { + sendJson(response, 200, { + ok: true, + service: 'server-command-runner', + cwd: projectRoot, + }); + return; + } + + if (!isAuthorized(request)) { + sendJson(response, 403, { + message: '๊ถŒํ•œ ํ† ํฐ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', + }); + return; + } + + const matchedPath = requestUrl.pathname.match(/^\/api\/server-commands\/([^/]+)\/actions\/restart$/); + if (request.method === 'POST' && matchedPath) { + const key = decodeURIComponent(matchedPath[1]); + const result = await runRestartCommand(key); + sendJson(response, result.statusCode, result.payload); + return; + } + + if (request.method === 'POST' && requestUrl.pathname === '/api/codex-live/execute') { + try { + const rawBody = await readRequestBody(request); + const payload = rawBody ? JSON.parse(rawBody) : {}; + await runCodexLiveExecution(payload, response); + } catch (error) { + sendJson(response, 500, { + message: error instanceof Error ? error.message : 'Codex ์‹คํ–‰ ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + return; + } + + const cancelMatch = requestUrl.pathname.match(/^\/api\/codex-live\/jobs\/([^/]+)\/cancel$/); + if (request.method === 'POST' && cancelMatch) { + const requestId = decodeURIComponent(cancelMatch[1]); + const activeExecution = activeCodexExecutions.get(requestId); + + if (!activeExecution) { + sendJson(response, 404, { + cancelled: false, + message: '์‹คํ–‰ ์ค‘์ธ Codex ์ž‘์—…์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + return; + } + + try { + activeExecution.child.kill('SIGTERM'); + setTimeout(() => { + const current = activeCodexExecutions.get(requestId); + + if (current?.child && !current.child.killed) { + current.child.kill('SIGKILL'); + } + }, 3000).unref?.(); + sendJson(response, 200, { + cancelled: true, + }); + } catch (error) { + sendJson(response, 500, { + cancelled: false, + message: error instanceof Error ? error.message : 'Codex ์ž‘์—… ์ทจ์†Œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + return; + } + + sendJson(response, 404, { + message: '์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค.', + }); +}); + +server.listen(port, host, () => { + void writeHeartbeat().catch(() => { + // noop + }); + const heartbeatTimer = setInterval(() => { + void writeHeartbeat().catch(() => { + // noop + }); + }, 10_000); + heartbeatTimer.unref(); + if (cpuWatchdogEnabled) { + const cpuWatchdogTimer = setInterval(() => { + void sampleCpuWatchdog(); + }, cpuWatchdogIntervalMs); + cpuWatchdogTimer.unref(); + void sampleCpuWatchdog(); + } + process.stdout.write(`server-command-runner listening on http://${host}:${port}\n`); +}); diff --git a/scripts/serve-app-dist.mjs b/scripts/serve-app-dist.mjs new file mode 100755 index 0000000..9c4a919 --- /dev/null +++ b/scripts/serve-app-dist.mjs @@ -0,0 +1,146 @@ +import { createReadStream, existsSync, statSync } from 'node:fs'; +import { extname, isAbsolute, join, normalize } from 'node:path'; +import { createServer } from 'node:http'; +import { Readable } from 'node:stream'; + +const port = Number(process.env.PORT ?? 5173); +const distDirName = process.env.APP_DIST_DIR ?? 'app-dist'; +const rootDir = normalize(isAbsolute(distDirName) ? distDirName : join(process.cwd(), distDirName)); +const workServerUrl = new URL(process.env.WORK_SERVER_URL ?? 'http://127.0.0.1:3100'); +const proxyPrefixes = ['/api', '/.codex_chat']; + +const mimeTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.woff2': 'font/woff2', +}; + +function looksLikeStaticAsset(requestedPath) { + const normalizedPath = requestedPath.split('?')[0] ?? requestedPath; + const extension = extname(normalizedPath); + + return extension.length > 0; +} + +function resolvePath(urlPath) { + const decodedPath = decodeURIComponent(urlPath.split('?')[0] || '/'); + const requestedPath = decodedPath === '/' ? '/index.html' : decodedPath; + const absolutePath = normalize(join(rootDir, requestedPath)); + + if (!absolutePath.startsWith(rootDir)) { + return null; + } + + if (existsSync(absolutePath) && statSync(absolutePath).isFile()) { + return absolutePath; + } + + if (looksLikeStaticAsset(requestedPath)) { + return null; + } + + return join(rootDir, 'index.html'); +} + +function shouldProxyRequest(urlPath = '/') { + return proxyPrefixes.some((prefix) => urlPath === prefix || urlPath.startsWith(`${prefix}/`)); +} + +function readRequestBody(request) { + return new Promise((resolve, reject) => { + const chunks = []; + + request.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + request.on('end', () => { + resolve(chunks.length > 0 ? Buffer.concat(chunks) : null); + }); + request.on('error', reject); + }); +} + +async function proxyRequest(request, response) { + const targetUrl = new URL(request.url ?? '/', workServerUrl); + const headers = new Headers(); + + Object.entries(request.headers).forEach(([key, value]) => { + if (value == null || key.toLowerCase() === 'host' || key.toLowerCase() === 'connection') { + return; + } + + if (Array.isArray(value)) { + value.forEach((item) => headers.append(key, item)); + return; + } + + headers.set(key, value); + }); + + const method = request.method ?? 'GET'; + const body = method === 'GET' || method === 'HEAD' ? undefined : await readRequestBody(request); + + try { + const upstreamResponse = await fetch(targetUrl, { + method, + headers, + body, + }); + + response.writeHead( + upstreamResponse.status, + Object.fromEntries(upstreamResponse.headers.entries()), + ); + + if (!upstreamResponse.body) { + response.end(); + return; + } + + Readable.fromWeb(upstreamResponse.body).pipe(response); + } catch (error) { + response.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' }); + response.end( + JSON.stringify({ + message: error instanceof Error ? error.message : 'Failed to proxy request to work server.', + }), + ); + } +} + +const server = createServer(async (request, response) => { + if (shouldProxyRequest(request.url ?? '/')) { + await proxyRequest(request, response); + return; + } + + const resolvedPath = resolvePath(request.url ?? '/'); + + if (!resolvedPath || !existsSync(resolvedPath)) { + response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end('Not Found'); + return; + } + + const extension = extname(resolvedPath); + const contentType = mimeTypes[extension] ?? 'application/octet-stream'; + + response.writeHead(200, { + 'Content-Type': contentType, + 'Cache-Control': extension === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable', + }); + + createReadStream(resolvedPath).pipe(response); +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`${distDirName} server listening on http://0.0.0.0:${port}`); +}); diff --git a/scripts/worklog-capture-utils.mjs b/scripts/worklog-capture-utils.mjs new file mode 100755 index 0000000..333c68c --- /dev/null +++ b/scripts/worklog-capture-utils.mjs @@ -0,0 +1,176 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; + +const KST_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', +}); + +const WORKLOG_TEMPLATE = `# {date} ์ž‘์—…์ผ์ง€ + +## ์˜ค๋Š˜ ์ž‘์—… + +- ํ™”๋ฉด ์บก์ฒ˜ ์ถ”๊ฐ€ ์˜ˆ์ • + +## ์Šคํฌ๋ฆฐ์ƒท + +- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท ์—†์Œ + +## ์†Œ์Šค + +### ํŒŒ์ผ 1: \`path/to/file.tsx\` + +- ๋ณ€๊ฒฝ ๋ชฉ์ ๊ณผ ํ•ต์‹ฌ ์ˆ˜์ • ๋‚ด์šฉ์„ ํ•œ ์ค„๋กœ ์ •๋ฆฌ + +\`\`\`diff +# ์ด ํŒŒ์ผ์˜ ํ•ต์‹ฌ diff +- before ++ after +\`\`\` + +### ํŒŒ์ผ 2: \`path/to/another-file.ts\` + +- ํ•„์š” ์—†์œผ๋ฉด ์ด ์„น์…˜์€ ์‚ญ์ œ + +## ์‹คํ–‰ ์ปค๋งจ๋“œ + +\`\`\`bash +\`\`\` + +## ๋ณ€๊ฒฝ ํŒŒ์ผ + +- +`; + +const OBSOLETE_WORKLOG_SECTION_TITLES = new Set([ + '์ปค๋ฐ‹ ๋ชฉ๋ก', + '๋ณ€๊ฒฝ ์š”์•ฝ', + '๋ณ€๊ฒฝ ํ†ต๊ณ„', + '๋ผ์ธ ํ†ต๊ณ„', +]); + +function normalizeLevelTwoHeading(section) { + const match = section.match(/^##\s+([^\n]+)$/m); + + if (!match) { + return ''; + } + + return match[1]?.replace(/\s+\(.*\)\s*$/u, '').trim() ?? ''; +} + +function stripObsoleteWorklogSections(content) { + const sections = content.split(/\n(?=##\s+)/); + const preservedSections = sections.filter((section) => { + const normalizedHeading = normalizeLevelTwoHeading(section); + return !OBSOLETE_WORKLOG_SECTION_TITLES.has(normalizedHeading); + }); + + return `${preservedSections.join('\n\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`; +} + +export async function ensureDirectory(dirPath) { + await fs.mkdir(dirPath, { recursive: true }); +} + +export function getKstDate(date = new Date()) { + return KST_DATE_FORMATTER.format(date); +} + +export async function ensureWorklogFile(worklogPath, captureDate) { + try { + await fs.access(worklogPath); + const content = await fs.readFile(worklogPath, 'utf8'); + const normalizedContent = stripObsoleteWorklogSections(content); + + if (normalizedContent !== content) { + await fs.writeFile(worklogPath, normalizedContent, 'utf8'); + } + } catch { + await ensureDirectory(path.dirname(worklogPath)); + const content = WORKLOG_TEMPLATE.replaceAll('{date}', captureDate); + await fs.writeFile(worklogPath, content, 'utf8'); + } +} + +export async function updateWorklogCaptureSection({ + worklogPath, + captureDate, + imageAlt, + markdownImagePath, +}) { + await ensureWorklogFile(worklogPath, captureDate); + + const imageLine = `![${imageAlt}](${markdownImagePath})`; + let content = await fs.readFile(worklogPath, 'utf8'); + + if (content.includes(imageLine)) { + return; + } + + if (!content.includes('## ์Šคํฌ๋ฆฐ์ƒท') && !content.includes('## ํ™”๋ฉด ์บก์ฒ˜')) { + content += `\n\n## ์Šคํฌ๋ฆฐ์ƒท\n\n${imageLine}\n`; + await fs.writeFile(worklogPath, content, 'utf8'); + return; + } + + const sections = content.split(/\n(?=##\s+)/); + const screenshotSections = []; + const remainingSections = []; + + for (const section of sections) { + if (/^##\s+(?:์Šคํฌ๋ฆฐ์ƒท|ํ™”๋ฉด ์บก์ฒ˜)\s*$/m.test(section)) { + screenshotSections.push(section); + } else { + remainingSections.push(section); + } + } + + const mergedScreenshotBody = screenshotSections + .map((section) => section.replace(/^##\s+(?:์Šคํฌ๋ฆฐ์ƒท|ํ™”๋ฉด ์บก์ฒ˜)\s*$/m, '').trim()) + .filter(Boolean) + .join('\n\n'); + + const screenshotLines = Array.from( + new Set( + [mergedScreenshotBody, imageLine] + .join('\n') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && line !== '- ์ €์žฅ์†Œ ๊ธฐ์ค€ ์—ฐ๊ฒฐ๋œ ์Šคํฌ๋ฆฐ์ƒท ์—†์Œ'), + ), + ); + + const mergedScreenshotSection = `## ์Šคํฌ๋ฆฐ์ƒท\n\n${screenshotLines.join('\n')}`.trim(); + const sourceSectionIndex = remainingSections.findIndex((section) => /^##\s+์†Œ์Šค\s*$/m.test(section)); + + if (sourceSectionIndex === -1) { + remainingSections.push(mergedScreenshotSection); + } else { + remainingSections.splice(Math.max(1, sourceSectionIndex), 0, mergedScreenshotSection); + } + + content = remainingSections.join('\n\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n'; + await fs.writeFile(worklogPath, content, 'utf8'); +} + +export function resolveCapturePaths({ + cwd = process.cwd(), + captureDate, + screenshotFileName, +}) { + const screenshotDir = path.join(cwd, 'docs', 'assets', 'worklogs', captureDate); + const screenshotPath = path.join(screenshotDir, screenshotFileName); + const worklogPath = path.join(cwd, 'docs', 'worklogs', `${captureDate}.md`); + const markdownImagePath = `../assets/worklogs/${captureDate}/${screenshotFileName}`; + + return { + screenshotDir, + screenshotPath, + worklogPath, + markdownImagePath, + }; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100755 index 0000000..4f9039b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react'; +import { getOrCreateClientId } from './app/main/clientIdentity'; +import { reportClientError } from './app/main/errorLogApi'; +import { AppShell } from './app/main'; +import { InitialLoadingOverlay } from './app/main/InitialLoadingOverlay'; +import { ReleasePendingMainModal } from './app/main/ReleasePendingMainModal'; +import { reportVisitorPageView } from './features/history/api'; +import { useAppStore } from './store'; + +const CHUNK_LOAD_RETRY_SESSION_KEY = 'ai-code-app.chunk-load-retried'; +const INITIAL_LOADING_MIN_VISIBLE_MS = 450; + +function shouldRetryChunkLoad(errorMessage: string) { + return /Failed to fetch dynamically imported module|Importing a module script failed|Load failed|ChunkLoadError/i.test( + errorMessage, + ); +} + +function retryChunkLoadOnce(errorMessage: string) { + if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') { + return false; + } + + if (!shouldRetryChunkLoad(errorMessage)) { + return false; + } + + if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') { + return false; + } + + sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1'); + window.location.reload(); + return true; +} + +function App() { + const { currentPage } = useAppStore(); + const lastTrackedPageIdRef = useRef(null); + const [showInitialLoading, setShowInitialLoading] = useState(true); + + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const handleError = (event: ErrorEvent) => { + const reportedError = event.error instanceof Error ? event.error : null; + const errorMessage = event.message || reportedError?.message || 'ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'; + + if (retryChunkLoadOnce(errorMessage)) { + return; + } + + void reportClientError({ + errorType: 'window.error', + errorName: reportedError?.name ?? null, + errorMessage, + stackTrace: reportedError?.stack ?? null, + requestPath: `${window.location.pathname}${window.location.search}${window.location.hash}`, + context: { + filename: event.filename || null, + line: event.lineno || null, + column: event.colno || null, + }, + }); + }; + + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + const reason = event.reason; + const reportedError = reason instanceof Error ? reason : null; + const errorMessage = + reportedError?.message || (typeof reason === 'string' ? reason : '์ฒ˜๋ฆฌ๋˜์ง€ ์•Š์€ Promise ๊ฑฐ์ ˆ์ด ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); + + if (retryChunkLoadOnce(errorMessage)) { + return; + } + + void reportClientError({ + errorType: 'unhandledrejection', + errorName: reportedError?.name ?? null, + errorMessage, + stackTrace: reportedError?.stack ?? null, + requestPath: `${window.location.pathname}${window.location.search}${window.location.hash}`, + context: { + reasonType: typeof reason, + }, + }); + }; + + window.addEventListener('error', handleError); + window.addEventListener('unhandledrejection', handleUnhandledRejection); + + return () => { + window.removeEventListener('error', handleError); + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + }; + }, []); + + useEffect(() => { + getOrCreateClientId(); + }, []); + + useEffect(() => { + const hideTimer = window.setTimeout(() => { + setShowInitialLoading(false); + }, INITIAL_LOADING_MIN_VISIBLE_MS); + + return () => { + window.clearTimeout(hideTimer); + }; + }, []); + + useEffect(() => { + if (lastTrackedPageIdRef.current === currentPage.id) { + return; + } + + lastTrackedPageIdRef.current = currentPage.id; + void reportVisitorPageView(currentPage); + }, [currentPage]); + + return ( + <> + + + {showInitialLoading ? : null} + + ); +} + +export default App; diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx new file mode 100755 index 0000000..b30895b --- /dev/null +++ b/src/app/main/AppShell.tsx @@ -0,0 +1,24 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import { MainLayout } from './layout/MainLayout'; +import { ApisPage } from './pages/ApisPage'; +import { ChatPage } from './pages/ChatPage'; +import { DocsPage } from './pages/DocsPage'; +import { PlansPage } from './pages/PlansPage'; +import { buildDocsPath, buildPlansPath } from './routes'; + +export function AppShell() { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/src/app/main/ChatNotificationBridge.tsx b/src/app/main/ChatNotificationBridge.tsx new file mode 100644 index 0000000..ec451d6 --- /dev/null +++ b/src/app/main/ChatNotificationBridge.tsx @@ -0,0 +1,447 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useAppStore } from '../../store'; +import { chatConnectionGateway, chatGateway } from './chatV2'; +import { + createNotificationMessage, + sendClientNotification, + shouldFallbackToLocalNotification, + showLocalClientNotification, +} from './notificationApi'; +import { + getChatClientSessionId, + loadStoredChatMessages, + persistStoredChatMessages, +} from './mainChatPanel'; +import type { ChatConversationSummary, ChatJobEvent, ChatMessage, ChatViewContext } from './mainChatPanel/types'; + +const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000; + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function createConversationPreviewText(text: string) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function createChatNotificationBody(text: string, fallback: string) { + const preview = createConversationPreviewText(text); + return preview || fallback; +} + +function createChatQuestionAnswerNotificationBody(args: { + questionText?: string | null; + answerText?: string | null; + fallback: string; +}) { + const questionPreview = createConversationPreviewText(args.questionText ?? ''); + const answerPreview = createConversationPreviewText(args.answerText ?? ''); + + if (questionPreview && answerPreview) { + return `์งˆ๋ฌธ: ${questionPreview}\n๋‹ต๋ณ€: ${answerPreview}`; + } + + if (answerPreview) { + return `๋‹ต๋ณ€: ${answerPreview}`; + } + + if (questionPreview) { + return `์งˆ๋ฌธ: ${questionPreview}`; + } + + return args.fallback; +} + +function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { + const questionPreview = createConversationPreviewText(questionText ?? ''); + return questionPreview ? `์งˆ๋ฌธ: ${questionPreview}` : fallback ?? ''; +} + +function normalizeNotificationDetailText(text?: string | null) { + const normalized = String(text ?? '').trim(); + return normalized || undefined; +} + +function buildChatNotificationLink(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId || typeof window === 'undefined') { + return ''; + } + + return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`; +} + +async function tryShowLocalChatNotification(args: { + title: string; + body: string; + threadId: string; + data: Record; +}) { + await showLocalClientNotification({ + title: args.title, + body: args.body, + threadId: args.threadId, + data: args.data, + }).catch(() => false); +} + +function findQuestionText(messages: ChatMessage[], requestId?: string | null) { + if (!requestId) { + return ''; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const candidate = messages[index]; + + if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) { + continue; + } + + return candidate.text; + } + + return ''; +} + +function findLatestCodexMessage(messages: ChatMessage[]) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const candidate = messages[index]; + + if (candidate.author === 'codex') { + return candidate; + } + } + + return null; +} + +export function ChatNotificationBridge() { + const { currentPage, focusedComponentId } = useAppStore(); + const [sessionId] = useState(() => getChatClientSessionId()); + const [messages, setMessages] = useState(() => loadStoredChatMessages(getChatClientSessionId())); + const [conversation, setConversation] = useState(null); + const notifiedIncomingMessageKeysRef = useRef([]); + const notifiedFailedJobKeysRef = useRef([]); + const lastPolledCodexMessageIdBySessionRef = useRef>({}); + const conversationPollInFlightRef = useRef(false); + const messagesRef = useRef(messages); + const currentContext: ChatViewContext = useMemo( + () => ({ + pageId: currentPage.id, + pageTitle: currentPage.title, + topMenu: currentPage.topMenu, + focusedComponentId, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + isStandaloneMode: isStandaloneDisplayMode(), + pageVisibilityState: + typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + chatTypeId: null, + chatTypeLabel: '', + chatTypeDescription: '', + chatTypeIsTemplate: false, + }), + [currentPage, focusedComponentId], + ); + + useEffect(() => { + messagesRef.current = messages; + persistStoredChatMessages(sessionId, messages); + }, [messages, sessionId]); + + const createChatNotification = ({ + targetSessionId, + conversationTitle, + title, + body, + previewText, + priority, + metadata, + }: { + targetSessionId: string; + conversationTitle?: string | null; + title: string; + body: string; + previewText?: string; + priority: 'normal' | 'high'; + metadata?: Record; + }) => { + const resolvedConversationTitle = conversationTitle || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'; + const linkUrl = buildChatNotificationLink(targetSessionId); + const notificationData = { + category: 'chat', + priority, + sessionId: targetSessionId, + conversationTitle: resolvedConversationTitle, + targetUrl: linkUrl, + linkUrl, + ...metadata, + }; + const serializedNotificationData = Object.fromEntries( + Object.entries(notificationData).flatMap(([key, value]) => { + if (value == null) { + return []; + } + + return [[key, String(value)]]; + }), + ); + const pushPayload = { + title, + body, + threadId: `chat:${targetSessionId}`, + data: serializedNotificationData, + }; + + return Promise.allSettled([ + createNotificationMessage({ + title, + body, + category: 'chat', + source: 'codex-live', + priority, + metadata: { + ...notificationData, + previewText, + linkLabel: '์ฑ„ํŒ… ๋ฐ”๋กœ ์—ด๊ธฐ', + }, + }), + sendClientNotification(pushPayload), + ]).then(async ([storedResult, pushResult]) => { + if (pushResult.status === 'rejected') { + await tryShowLocalChatNotification(pushPayload); + } else if (shouldFallbackToLocalNotification(pushResult.value)) { + await tryShowLocalChatNotification(pushPayload); + } + + if (storedResult.status === 'fulfilled') { + return storedResult.value; + } + + if (pushResult.status === 'fulfilled') { + return pushResult.value; + } + + throw storedResult.reason; + }).catch(() => undefined); + }; + + const handleIncomingMessageEvent = (incomingMessage: ChatMessage) => { + if (incomingMessage.author !== 'codex' || conversation?.notifyOffline !== true) { + return; + } + + const notificationKey = `${sessionId}:${incomingMessage.id}:${incomingMessage.timestamp}`; + + if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) { + return; + } + + notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120); + + const questionText = findQuestionText(messagesRef.current, incomingMessage.clientRequestId); + + void createChatNotification({ + targetSessionId: sessionId, + conversationTitle: conversation?.title, + title: 'Codex Live ์ƒˆ ๋ฉ”์‹œ์ง€', + body: createChatQuestionAnswerNotificationBody({ + questionText, + answerText: incomingMessage.text, + fallback: createChatNotificationBody( + incomingMessage.text, + `${conversation?.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์— ์ƒˆ ์‘๋‹ต์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ), + }), + previewText: createChatQuestionOnlyNotificationPreview( + questionText, + createChatNotificationBody( + incomingMessage.text, + `${conversation?.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์— ์ƒˆ ์‘๋‹ต์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ), + ), + priority: 'normal', + metadata: { + messageId: incomingMessage.id, + messageTimestamp: incomingMessage.timestamp, + questionText: normalizeNotificationDetailText(questionText), + answerText: normalizeNotificationDetailText(incomingMessage.text), + }, + }); + }; + + const handleJobEvent = (event: ChatJobEvent) => { + if (event.status !== 'failed' || conversation?.notifyOffline !== true) { + return; + } + + const notificationKey = `${sessionId}:${event.requestId}:${event.status}`; + + if (notifiedFailedJobKeysRef.current.includes(notificationKey)) { + return; + } + + notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80); + + const questionText = findQuestionText(messagesRef.current, event.requestId); + + void createChatNotification({ + targetSessionId: sessionId, + conversationTitle: conversation?.title, + title: 'Codex Live ์š”์ฒญ ์‹คํŒจ', + body: createChatQuestionAnswerNotificationBody({ + questionText, + answerText: event.message, + fallback: `${conversation?.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์˜ ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.`, + }), + previewText: createChatQuestionOnlyNotificationPreview( + questionText, + `${conversation?.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์˜ ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ), + priority: 'high', + metadata: { + requestId: event.requestId, + status: event.status, + questionText: normalizeNotificationDetailText(questionText), + answerText: normalizeNotificationDetailText(event.message), + }, + }); + }; + + useEffect(() => { + let cancelled = false; + + const pollConversations = async (seedOnly: boolean) => { + if (conversationPollInFlightRef.current) { + return; + } + + conversationPollInFlightRef.current = true; + + try { + const items = await chatGateway.listConversations(); + + if (cancelled) { + return; + } + + setConversation(items.find((item) => item.sessionId === sessionId) ?? null); + + const enabledItems = items.filter((item) => item.notifyOffline === true); + + if (enabledItems.length === 0) { + return; + } + + const detailResults = await Promise.allSettled( + enabledItems.map(async (item) => ({ + item, + detail: await chatGateway.getConversationDetail(item.sessionId), + })), + ); + + for (const result of detailResults) { + if (cancelled || result.status !== 'fulfilled') { + continue; + } + + const { item, detail } = result.value; + const latestCodexMessage = findLatestCodexMessage(detail.messages); + + if (!latestCodexMessage) { + continue; + } + + const previousMessageId = lastPolledCodexMessageIdBySessionRef.current[item.sessionId]; + lastPolledCodexMessageIdBySessionRef.current[item.sessionId] = latestCodexMessage.id; + + if (seedOnly || previousMessageId == null || latestCodexMessage.id <= previousMessageId) { + continue; + } + + const notificationKey = `${item.sessionId}:${latestCodexMessage.id}:${latestCodexMessage.timestamp}`; + + if (notifiedIncomingMessageKeysRef.current.includes(notificationKey)) { + continue; + } + + notifiedIncomingMessageKeysRef.current = [...notifiedIncomingMessageKeysRef.current, notificationKey].slice(-120); + + const questionText = findQuestionText(detail.messages, latestCodexMessage.clientRequestId); + + void createChatNotification({ + targetSessionId: item.sessionId, + conversationTitle: item.title, + title: 'Codex Live ์ƒˆ ๋ฉ”์‹œ์ง€', + body: createChatQuestionAnswerNotificationBody({ + questionText, + answerText: latestCodexMessage.text, + fallback: createChatNotificationBody( + latestCodexMessage.text, + `${item.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์— ์ƒˆ ์‘๋‹ต์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ), + }), + previewText: createChatQuestionOnlyNotificationPreview( + questionText, + createChatNotificationBody( + latestCodexMessage.text, + `${item.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'} ์ฑ„ํŒ…๋ฐฉ์— ์ƒˆ ์‘๋‹ต์ด ๋„์ฐฉํ–ˆ์Šต๋‹ˆ๋‹ค.`, + ), + ), + priority: 'normal', + metadata: { + messageId: latestCodexMessage.id, + messageTimestamp: latestCodexMessage.timestamp, + questionText: normalizeNotificationDetailText(questionText), + answerText: normalizeNotificationDetailText(latestCodexMessage.text), + }, + }); + } + } catch { + // Ignore polling errors and retry on the next cycle. + } finally { + conversationPollInFlightRef.current = false; + } + }; + + void pollConversations(true); + + const intervalId = window.setInterval(() => { + void pollConversations(false); + }, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS); + + const handleResume = () => { + void pollConversations(false); + }; + + window.addEventListener('focus', handleResume); + window.addEventListener('pageshow', handleResume); + document.addEventListener('visibilitychange', handleResume); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + window.removeEventListener('focus', handleResume); + window.removeEventListener('pageshow', handleResume); + document.removeEventListener('visibilitychange', handleResume); + }; + }, [sessionId]); + + chatConnectionGateway.useConnection({ + sessionId, + currentContext, + setMessages, + onMessageEvent: handleIncomingMessageEvent, + onJobEvent: handleJobEvent, + }); + + return null; +} diff --git a/src/app/main/ChatNotificationBridgeV2.tsx b/src/app/main/ChatNotificationBridgeV2.tsx new file mode 100644 index 0000000..38dc248 --- /dev/null +++ b/src/app/main/ChatNotificationBridgeV2.tsx @@ -0,0 +1,258 @@ +// @ts-nocheck +import { useEffect, useRef } from 'react'; +import { + createNotificationMessage, + sendClientNotification, + shouldFallbackToLocalNotification, + showLocalClientNotification, +} from './notificationApi'; +import { chatGateway } from './chatV2'; +import type { ChatConversationRequest, ChatMessage } from './mainChatPanel/types'; + +const BACKGROUND_CONVERSATION_POLL_INTERVAL_MS = 15_000; +const MAX_NOTIFICATION_DETAIL_POLLS = 3; + +function createConversationPreviewText(text: string) { + const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function createChatQuestionAnswerNotificationBody(args: { + questionText?: string | null; + answerText?: string | null; + fallback: string; +}) { + const questionPreview = createConversationPreviewText(args.questionText ?? ''); + const answerPreview = createConversationPreviewText(args.answerText ?? ''); + + if (questionPreview && answerPreview) { + return `์งˆ๋ฌธ: ${questionPreview}\n๋‹ต๋ณ€: ${answerPreview}`; + } + + if (answerPreview) { + return `๋‹ต๋ณ€: ${answerPreview}`; + } + + if (questionPreview) { + return `์งˆ๋ฌธ: ${questionPreview}`; + } + + return args.fallback; +} + +function createChatQuestionOnlyNotificationPreview(questionText?: string | null, fallback?: string) { + const questionPreview = createConversationPreviewText(questionText ?? ''); + return questionPreview ? `์งˆ๋ฌธ: ${questionPreview}` : fallback ?? ''; +} + +function normalizeNotificationDetailText(text?: string | null) { + const normalized = String(text ?? '').trim(); + return normalized || undefined; +} + +function buildChatNotificationLink(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId || typeof window === 'undefined') { + return ''; + } + + return `${window.location.origin}/chat/live?sessionId=${encodeURIComponent(normalizedSessionId)}`; +} + +async function tryShowLocalChatNotification(args: { + title: string; + body: string; + threadId: string; + data: Record; +}) { + await showLocalClientNotification({ + title: args.title, + body: args.body, + threadId: args.threadId, + data: args.data, + }).catch(() => false); +} + +function findLatestCodexMessage(messages: ChatMessage[]) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const candidate = messages[index]; + + if (candidate.author === 'codex') { + return candidate; + } + } + + return null; +} + +function findQuestionText(messages: ChatMessage[], requestId?: string | null) { + if (!requestId) { + return ''; + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const candidate = messages[index]; + + if (candidate.author !== 'user' || candidate.clientRequestId !== requestId) { + continue; + } + + return candidate.text; + } + + return ''; +} + +function findLatestFailedRequest(requests: ChatConversationRequest[]) { + for (let index = requests.length - 1; index >= 0; index -= 1) { + const candidate = requests[index]; + + if (candidate.status === 'failed') { + return candidate; + } + } + + return null; +} + +function shouldNotifyWhileAway() { + if (typeof document === 'undefined') { + return true; + } + + if (document.visibilityState === 'hidden') { + return true; + } + + if (typeof document.hasFocus === 'function') { + return !document.hasFocus(); + } + + return false; +} + +function shouldPollConversationNotifications() { + if (typeof document === 'undefined') { + return true; + } + + if (document.visibilityState === 'hidden') { + return true; + } + + if (typeof document.hasFocus === 'function') { + return !document.hasFocus(); + } + + return false; +} + +function getConversationActivityTime(item: { lastMessageAt?: string | null; updatedAt?: string | null }) { + const candidate = item.lastMessageAt || item.updatedAt || ''; + const parsed = candidate ? Date.parse(candidate) : Number.NaN; + return Number.isNaN(parsed) ? 0 : parsed; +} + +function selectNotificationPollingCandidates< + T extends { + notifyOffline: boolean; + hasUnreadResponse: boolean; + currentJobStatus: string | null; + currentRequestId: string | null; + lastMessageAt: string | null; + updatedAt: string; + }, +>(items: T[]) { + return items + .filter((item) => item.notifyOffline === true) + .filter((item) => item.hasUnreadResponse || item.currentJobStatus === 'started' || item.currentJobStatus === 'queued' || item.currentRequestId) + .sort((left, right) => getConversationActivityTime(right) - getConversationActivityTime(left)) + .slice(0, MAX_NOTIFICATION_DETAIL_POLLS); +} + +export function ChatNotificationBridgeV2() { + const notifiedFailedJobKeysRef = useRef([]); + const lastPolledCodexMessageIdBySessionRef = useRef>({}); + const lastFailedRequestKeyBySessionRef = useRef>({}); + + const createChatNotification = ({ + targetSessionId, + conversationTitle, + title, + body, + previewText, + priority, + metadata, + }: { + targetSessionId: string; + conversationTitle?: string | null; + title: string; + body: string; + previewText?: string; + priority: 'normal' | 'high'; + metadata?: Record; + }) => { + const resolvedConversationTitle = conversationTitle || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'; + const linkUrl = buildChatNotificationLink(targetSessionId); + const notificationData = { + category: 'chat', + priority, + sessionId: targetSessionId, + conversationTitle: resolvedConversationTitle, + targetUrl: linkUrl, + linkUrl, + ...metadata, + }; + const serializedNotificationData = Object.fromEntries( + Object.entries(notificationData).flatMap(([key, value]) => { + if (value == null) { + return []; + } + + return [[key, String(value)]]; + }), + ); + const pushPayload = { + title, + body, + threadId: `chat:${targetSessionId}`, + data: serializedNotificationData, + }; + + return Promise.allSettled([ + createNotificationMessage({ + title, + body, + category: 'chat', + source: 'codex-live', + priority, + metadata: { + ...notificationData, + previewText, + linkLabel: '์ฑ„ํŒ… ๋ฐ”๋กœ ์—ด๊ธฐ', + }, + }), + sendClientNotification(pushPayload), + ]) + .then(async ([storedResult, pushResult]) => { + if (pushResult.status === 'rejected') { + await tryShowLocalChatNotification(pushPayload); + } else if (shouldFallbackToLocalNotification(pushResult.value)) { + await tryShowLocalChatNotification(pushPayload); + } + + if (storedResult.status === 'fulfilled') { + return storedResult.value; + } + + if (pushResult.status === 'fulfilled') { + return pushResult.value; + } + + throw storedResult.reason; + }) + .catch(() => undefined); + }; + return null; +} diff --git a/src/app/main/ChatRuntimeBridge.tsx b/src/app/main/ChatRuntimeBridge.tsx new file mode 100644 index 0000000..9bef611 --- /dev/null +++ b/src/app/main/ChatRuntimeBridge.tsx @@ -0,0 +1,68 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useAppStore } from '../../store'; +import { + fetchChatRuntimeSnapshot, + getChatClientSessionId, + setSharedChatRuntimeSnapshot, + useChatConnection, +} from './mainChatPanel'; +import type { ChatMessage, ChatViewContext } from './mainChatPanel/types'; + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +export function ChatRuntimeBridge() { + const { currentPage, focusedComponentId } = useAppStore(); + const [sessionId] = useState(() => getChatClientSessionId()); + const [, setMessages] = useState([]); + + const currentContext: ChatViewContext = useMemo( + () => ({ + pageId: currentPage.id, + pageTitle: currentPage.title, + topMenu: currentPage.topMenu, + focusedComponentId, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + isStandaloneMode: isStandaloneDisplayMode(), + pageVisibilityState: + typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + chatTypeId: null, + chatTypeLabel: '', + chatTypeDescription: '', + chatTypeIsTemplate: false, + }), + [currentPage, focusedComponentId], + ); + + useEffect(() => { + let cancelled = false; + + void fetchChatRuntimeSnapshot() + .then((snapshot) => { + if (!cancelled) { + setSharedChatRuntimeSnapshot(snapshot); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, []); + + useChatConnection({ + sessionId, + currentContext, + setMessages, + }); + + return null; +} diff --git a/src/app/main/ChatRuntimeBridgeV2.tsx b/src/app/main/ChatRuntimeBridgeV2.tsx new file mode 100644 index 0000000..5dd2823 --- /dev/null +++ b/src/app/main/ChatRuntimeBridgeV2.tsx @@ -0,0 +1,64 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useAppStore } from '../../store'; +import { getChatClientSessionId } from './mainChatPanel'; +import { chatConnectionGateway, chatGateway } from './chatV2'; +import type { ChatMessage, ChatViewContext } from './mainChatPanel/types'; + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +export function ChatRuntimeBridgeV2() { + const { currentPage, focusedComponentId } = useAppStore(); + const [sessionId] = useState(() => getChatClientSessionId()); + const [, setMessages] = useState([]); + + const currentContext: ChatViewContext = useMemo( + () => ({ + pageId: currentPage.id, + pageTitle: currentPage.title, + topMenu: currentPage.topMenu, + focusedComponentId, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + isStandaloneMode: isStandaloneDisplayMode(), + pageVisibilityState: + typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + chatTypeId: null, + chatTypeLabel: '', + chatTypeDescription: '', + chatTypeIsTemplate: false, + }), + [currentPage, focusedComponentId], + ); + + useEffect(() => { + let cancelled = false; + + void chatGateway.fetchRuntimeSnapshot() + .then((snapshot) => { + if (!cancelled) { + chatConnectionGateway.setSharedRuntimeSnapshot(snapshot); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, []); + + chatConnectionGateway.useConnection({ + sessionId, + currentContext, + setMessages, + }); + + return null; +} diff --git a/src/app/main/ChatSourceChangesPage.tsx b/src/app/main/ChatSourceChangesPage.tsx new file mode 100644 index 0000000..7a9643f --- /dev/null +++ b/src/app/main/ChatSourceChangesPage.tsx @@ -0,0 +1,797 @@ +import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { fetchPlanActionHistories, fetchPlanItems } from '../../features/planBoard/api'; +import { chatGateway } from './chatV2'; +import type { ChatMessage } from './mainChatPanel/types'; +import type { ChatConversationRequest } from './mainChatPanel/types'; + +const { Paragraph, Text, Title } = Typography; + +type ChatSourceChangeEntry = { + id: string; + sessionId: string; + conversationTitle: string; + requestId: string; + requestTitle: string; + questionText: string; + answerText: string; + status: ChatConversationRequest['status']; + sourceChangedAt: string; + updatedAt: string; + featureTags: string[]; + changedFiles: string[]; + currentSourceFiles: string[]; + diffBlocks: string[]; + deploymentStatus: DeploymentFilterValue; + currentSourceStatus: CurrentSourceStatus; +}; + +type DeploymentFilterValue = 'all' | 'pre-deploy' | 'deployed'; +type CurrentSourceStatus = 'applied' | 'not-applied'; +type CurrentSourceFilterValue = 'all' | CurrentSourceStatus; + +const DEPLOYMENT_FILTER_OPTIONS: Array<{ label: string; value: DeploymentFilterValue }> = [ + { label: '์ „์ฒด', value: 'all' }, + { label: '๋ฐฐํฌ ์ „', value: 'pre-deploy' }, + { label: '๋ฐฐํฌ๋จ', value: 'deployed' }, +]; + +const CURRENT_SOURCE_FILTER_OPTIONS: Array<{ label: string; value: CurrentSourceFilterValue }> = [ + { label: '์ „์ฒด', value: 'all' }, + { label: 'ํ˜„์žฌ ์†Œ์Šค ์ ์šฉ', value: 'applied' }, + { label: 'ํ˜„์žฌ ์†Œ์Šค ๋ฏธ์ ์šฉ', value: 'not-applied' }, +]; + +const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; + +function formatDateTime(value: string | null | undefined) { + if (!value) { + return '๋ฏธ๊ธฐ๋ก'; + } + + return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); +} + +function createCompactText(value: string | null | undefined, limit = 88) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + return normalized.length > limit ? `${normalized.slice(0, limit - 1)}โ€ฆ` : normalized; +} + +function createRequestTitle(userText: string, fallback: string) { + const compact = createCompactText(userText, 72); + return compact || fallback; +} + +function extractDiffBlocks(text: string) { + return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g)) + .map((match) => match[1]?.trim() ?? '') + .filter(Boolean); +} + +function hasSourceEvidenceText(text: string) { + const normalized = String(text ?? '').trim(); + + if (!normalized) { + return false; + } + + return ( + /```diff[^\n]*\n[\s\S]*?```/m.test(normalized) || + /^(?:diff --git a\/[^\s]+ b\/[^\s]+|\+\+\+ b\/[^\s]+|--- a\/[^\s]+)$/m.test(normalized) || + /\/workspace\/main-project\/[^\s)]+/.test(normalized) || + /\/api\/chat\/resources\/[^\s)`]+/.test(normalized) || + /\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/.test(normalized) || + /\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/.test(normalized) + ); +} + +function normalizeWorkspaceFilePath(value: string) { + const normalized = String(value ?? '') + .trim() + .replace(/\\/g, '/') + .replace(/^file:\/\//, '') + .replace(/[)>.,]+$/, '') + .replace(/:\d+(?::\d+)?$/, ''); + + if (!normalized) { + return ''; + } + + const resourceMarker = '/resource/'; + const resourceIndex = normalized.lastIndexOf(resourceMarker); + + if (resourceIndex >= 0) { + const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, ''); + return innerPath; + } + + const workspaceMarker = '/workspace/main-project/'; + const workspaceIndex = normalized.lastIndexOf(workspaceMarker); + + if (workspaceIndex >= 0) { + return normalized.slice(workspaceIndex + workspaceMarker.length); + } + + const apiResourceMarker = '/api/chat/resources/'; + const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker); + + if (apiResourceIndex >= 0) { + return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, ''); + } + + return normalized.replace(/^\/+/, '').replace(/^\.\//, ''); +} + +function isCurrentSourcePath(path: string) { + return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function extractCurrentSourceFiles(text: string) { + const textWithoutChatResourcePaths = text + .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ') + .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' '); + + const diffPathMatches = Array.from( + text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), + ) + .flatMap((match) => [match[1], match[2], match[3]]) + .filter((value): value is string => Boolean(value)) + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + const workspacePathMatches = [ + ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) + .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), + ...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), + ] + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []) + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60); +} + +function extractChangedFiles(text: string) { + const diffPathMatches = Array.from( + text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), + ) + .flatMap((match) => [match[1], match[2], match[3]]) + .filter((value): value is string => Boolean(value)); + + const matches = [ + ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) + .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), + ...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), + ...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []), + ...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []), + ...(text.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []), + ...diffPathMatches, + ]; + + return Array.from( + new Set( + matches + .map((item) => normalizeWorkspaceFilePath(item)) + .filter(Boolean), + ), + ).slice(0, 60); +} + +function deriveFeatureTags(files: string[]) { + const tags = new Set(); + + files.forEach((file) => { + const segments = file.split('/').filter(Boolean); + + if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) { + tags.add(`feature:${segments[2]}`); + return; + } + + if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) { + tags.add(`component:${segments[2]}`); + return; + } + + if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) { + tags.add(`widget:${segments[2]}`); + return; + } + + if (segments[0] === 'docs' && segments[1]) { + tags.add(`docs:${segments[1]}`); + return; + } + + if (segments[0]) { + tags.add(segments[0]); + } + }); + + return Array.from(tags); +} + +function isMeaningfulSourceChangeMessage(text: string) { + const normalized = text.trim(); + + if (!normalized) { + return false; + } + + if (normalized === '์‘๋‹ต์„ ์ค€๋น„ํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค...') { + return false; + } + + return normalized.length >= 12 || hasSourceEvidenceText(normalized); +} + +function getTimeValue(value: string | null | undefined) { + const parsed = new Date(value ?? '').getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +function appendUniqueText(target: string[], value: string | null | undefined) { + const normalized = String(value ?? '').trim(); + + if (!normalized || target.includes(normalized)) { + return; + } + + target.push(normalized); +} + +function resolveDeploymentStatus(updatedAt: string, latestReleaseCompletedAt: string | null): DeploymentFilterValue { + if (!latestReleaseCompletedAt) { + return 'pre-deploy'; + } + + return getTimeValue(updatedAt) > getTimeValue(latestReleaseCompletedAt) ? 'pre-deploy' : 'deployed'; +} + +function isTestReleaseTarget(value: string | null | undefined) { + const normalized = String(value ?? '').trim().toLowerCase(); + + if (!normalized) { + return false; + } + + return ( + normalized === 'test' || + normalized.includes('test.sm-home.cloud') || + normalized.includes('/test') || + normalized.startsWith('test-') || + normalized.endsWith('-test') || + normalized.includes(' test ') + ); +} + +function resolveSourceChangedAt( + request: ChatConversationRequest, + messages: ChatMessage[], + nextRequest: ChatConversationRequest | undefined, +) { + const timestamps = collectRequestExplanationMessageTimestamps(request, messages, nextRequest); + + return ( + timestamps[0] ?? + request.answeredAt ?? + request.terminalAt ?? + request.updatedAt ?? + request.createdAt + ); +} + +function getDeploymentStatusLabel(value: DeploymentFilterValue) { + if (value === 'deployed') { + return 'test ๋ฐฐํฌ๋จ'; + } + + if (value === 'pre-deploy') { + return 'test ๋ฐฐํฌ ์ „'; + } + + return '์ „์ฒด'; +} + +function collectRequestExplanationTexts( + request: ChatConversationRequest, + messages: ChatMessage[], + nextRequest: ChatConversationRequest | undefined, +) { + const texts: string[] = []; + appendUniqueText(texts, request.responseText); + + if (request.responseMessageId != null) { + const matchedMessage = messages.find((message) => message.id === request.responseMessageId); + + if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) { + appendUniqueText(texts, matchedMessage.text); + } + } + + const directMessageMatches = messages + .filter( + (message) => + message.author === 'codex' && + message.clientRequestId === request.requestId && + isMeaningfulSourceChangeMessage(String(message.text ?? '')), + ); + directMessageMatches.forEach((message) => { + appendUniqueText(texts, message.text); + }); + + const requestCreatedAt = getTimeValue(request.createdAt); + const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); + const fallbackMessages = messages.filter((message) => { + if (message.author !== 'codex') { + return false; + } + + const messageTimestamp = getTimeValue(message.timestamp); + + if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) { + return false; + } + + if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) { + return false; + } + + return isMeaningfulSourceChangeMessage(String(message.text ?? '')); + }); + + fallbackMessages + .filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId) + .forEach((message) => { + appendUniqueText(texts, message.text); + }); + + appendUniqueText(texts, request.statusMessage); + + return texts; +} + +function collectRequestExplanationMessageTimestamps( + request: ChatConversationRequest, + messages: ChatMessage[], + nextRequest: ChatConversationRequest | undefined, +) { + const timestamps: string[] = []; + + if (request.responseMessageId != null) { + const matchedMessage = messages.find((message) => message.id === request.responseMessageId); + + if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) { + appendUniqueText(timestamps, matchedMessage.timestamp); + } + } + + messages + .filter( + (message) => + message.author === 'codex' && + message.clientRequestId === request.requestId && + isMeaningfulSourceChangeMessage(String(message.text ?? '')), + ) + .forEach((message) => { + appendUniqueText(timestamps, message.timestamp); + }); + + const requestCreatedAt = getTimeValue(request.createdAt); + const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); + messages + .filter((message) => { + if (message.author !== 'codex') { + return false; + } + + const messageTimestamp = getTimeValue(message.timestamp); + + if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) { + return false; + } + + if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) { + return false; + } + + return isMeaningfulSourceChangeMessage(String(message.text ?? '')); + }) + .filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId) + .forEach((message) => { + appendUniqueText(timestamps, message.timestamp); + }); + + return timestamps.sort((left, right) => getTimeValue(left) - getTimeValue(right)); +} + +function resolveRequestExplanation( + request: ChatConversationRequest, + messages: ChatMessage[], + nextRequest: ChatConversationRequest | undefined, +) { + return collectRequestExplanationTexts(request, messages, nextRequest).join('\n\n').trim(); +} + +function resolveRequestQuestion(request: ChatConversationRequest, messages: ChatMessage[]) { + const requestUserText = String(request.userText ?? '').trim(); + + if (requestUserText) { + return requestUserText; + } + + if (request.userMessageId != null) { + const matchedMessage = messages.find((message) => message.id === request.userMessageId); + + if (matchedMessage?.author === 'user') { + return String(matchedMessage.text ?? '').trim(); + } + } + + const directMatch = messages.find( + (message) => message.author === 'user' && message.clientRequestId === request.requestId, + ); + + return String(directMatch?.text ?? '').trim(); +} + +function buildSourceChangeEntry( + conversationTitle: string, + sessionId: string, + request: ChatConversationRequest, + messages: ChatMessage[], + nextRequest: ChatConversationRequest | undefined, + latestReleaseCompletedAt: string | null, +) { + const questionText = resolveRequestQuestion(request, messages); + const answerText = resolveRequestExplanation(request, messages, nextRequest); + const sourceChangedAt = resolveSourceChangedAt(request, messages, nextRequest); + const diffBlocks = extractDiffBlocks(answerText); + const changedFiles = extractChangedFiles(answerText); + const currentSourceFiles = extractCurrentSourceFiles(answerText); + const featureTags = deriveFeatureTags(changedFiles); + const hasSourceEvidence = diffBlocks.length > 0 || changedFiles.length > 0; + + if (!hasSourceEvidence) { + return null; + } + + return { + id: `${sessionId}:${request.requestId}`, + sessionId, + conversationTitle, + requestId: request.requestId, + requestTitle: createRequestTitle(questionText, request.requestId), + questionText, + answerText, + status: request.status, + sourceChangedAt, + updatedAt: request.updatedAt, + featureTags, + changedFiles, + currentSourceFiles, + diffBlocks, + deploymentStatus: currentSourceFiles.length > 0 + ? resolveDeploymentStatus(sourceChangedAt, latestReleaseCompletedAt) + : 'pre-deploy', + currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied', + } satisfies ChatSourceChangeEntry; +} + +export function ChatSourceChangesPage() { + const [entries, setEntries] = useState([]); + const [selectedEntryId, setSelectedEntryId] = useState(null); + const [searchText, setSearchText] = useState(''); + const [deploymentFilter, setDeploymentFilter] = useState('pre-deploy'); + const [currentSourceFilter, setCurrentSourceFilter] = useState('all'); + const [latestReleaseCompletedAt, setLatestReleaseCompletedAt] = useState(null); + const [loading, setLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + + const loadChanges = async () => { + setLoading(true); + setErrorMessage(null); + + try { + const planItems = await fetchPlanItems('all'); + const testPlanItems = planItems.filter((item) => isTestReleaseTarget(item.releaseTarget)); + const actionResults = await Promise.allSettled(planItems.map((item) => fetchPlanActionHistories(item.id))); + const nextLatestReleaseCompletedAt = + actionResults + .flatMap((result, index) => + result.status === 'fulfilled' && testPlanItems.some((item) => item.id === planItems[index]?.id) + ? result.value + : [], + ) + .filter((history) => history.actionType === 'release๋ฐ˜์˜์™„๋ฃŒ') + .map((history) => history.createdAt) + .sort((left, right) => getTimeValue(right) - getTimeValue(left))[0] ?? null; + const conversations = await chatGateway.listConversations(); + const details = await Promise.allSettled( + conversations.map(async (conversation) => ({ + conversation, + detail: await chatGateway.getConversationDetail(conversation.sessionId), + })), + ); + + if (cancelled) { + return; + } + + const nextEntries = details + .flatMap((result) => { + if (result.status !== 'fulfilled') { + return []; + } + + const { conversation, detail } = result.value; + return detail.requests + .map((request, index, requests) => + buildSourceChangeEntry( + conversation.title || '์ƒˆ ๋Œ€ํ™”', + conversation.sessionId, + request, + detail.messages, + requests[index + 1], + nextLatestReleaseCompletedAt, + ), + ) + .filter((item): item is ChatSourceChangeEntry => Boolean(item)); + }) + .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()); + + setLatestReleaseCompletedAt(nextLatestReleaseCompletedAt); + setEntries(nextEntries); + setSelectedEntryId((previous) => { + if (previous && nextEntries.some((entry) => entry.id === previous)) { + return previous; + } + + return nextEntries[0]?.id ?? null; + }); + } catch (error) { + if (!cancelled) { + setErrorMessage(error instanceof Error ? error.message : 'Codex Live ๋ณ€๊ฒฝ ์ด๋ ฅ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void loadChanges(); + + return () => { + cancelled = true; + }; + }, []); + + const filteredEntries = useMemo(() => { + const keyword = searchText.trim().toLowerCase(); + + return entries.filter((entry) => { + if (deploymentFilter !== 'all' && entry.deploymentStatus !== deploymentFilter) { + return false; + } + + if (currentSourceFilter !== 'all' && entry.currentSourceStatus !== currentSourceFilter) { + return false; + } + + if (!keyword) { + return true; + } + + return [ + entry.conversationTitle, + entry.requestTitle, + entry.questionText, + entry.answerText, + ...entry.featureTags, + ...entry.changedFiles, + ...entry.currentSourceFiles, + ] + .join(' ') + .toLowerCase() + .includes(keyword); + }); + }, [currentSourceFilter, deploymentFilter, entries, searchText]); + + const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null; + + return ( + + + + + Codex Live ๋ณ€๊ฒฝ ์ด๋ ฅ + + + ์ฑ„ํŒ… ์š”์ฒญ ์ค‘ ์‹ค์ œ ์†Œ์Šค ์ˆ˜์ • ํ”์ ์ด ๋‚จ์€ ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„์„œ test ๋„๋ฉ”์ธ ๋ฐฐํฌ ๋ฐ˜์˜ ์—ฌ๋ถ€ ๊ธฐ์ค€์œผ๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + + { + setSearchText(event.target.value); + }} + /> + + { + setCurrentSourceFilter(values); + }} + /> + + {latestReleaseCompletedAt + ? `์ตœ๊ทผ test ๋„๋ฉ”์ธ ๋ฐฐํฌ ์™„๋ฃŒ ์‹œ๊ฐ ๊ธฐ์ค€: ${formatDateTime(latestReleaseCompletedAt)}` + : '๊ธฐ๋ก๋œ test ๋„๋ฉ”์ธ ๋ฐฐํฌ ์™„๋ฃŒ ์ด๋ ฅ์ด ์—†์–ด ํ˜„์žฌ ํ•ญ๋ชฉ์€ ๋ชจ๋‘ ๋ฐฐํฌ ์ „์œผ๋กœ ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.'} + + + + + + {loading ? ( + +
+ +
+
+ ) : errorMessage ? ( + + {errorMessage} + + ) : filteredEntries.length === 0 ? ( + + + + ) : ( +
+ + ( + { + setSelectedEntryId(entry.id); + }} + > + + + {entry.conversationTitle} + {entry.status} + + {entry.currentSourceStatus === 'applied' ? 'ํ˜„์žฌ ์†Œ์Šค ์ ์šฉ' : 'ํ˜„์žฌ ์†Œ์Šค ๋ฏธ์ ์šฉ'} + + + {getDeploymentStatusLabel(entry.deploymentStatus)} + + + {entry.requestTitle} + {formatDateTime(entry.updatedAt)} + + {entry.featureTags.map((tag) => ( + {tag} + ))} + + + + )} + /> + + + + {selectedEntry ? ( + + + + {selectedEntry.requestTitle} + + + {selectedEntry.conversationTitle} ยท ๋ณ€๊ฒฝ ๋ฐ˜์˜ ์‹œ๊ฐ {formatDateTime(selectedEntry.sourceChangedAt)} + + + + + {selectedEntry.status} + + {selectedEntry.currentSourceStatus === 'applied' ? 'ํ˜„์žฌ ์†Œ์Šค ์ ์šฉ' : 'ํ˜„์žฌ ์†Œ์Šค ๋ฏธ์ ์šฉ'} + + + {getDeploymentStatusLabel(selectedEntry.deploymentStatus)} + + {selectedEntry.featureTags.map((tag) => ( + {tag} + ))} + + + + + {selectedEntry.questionText || '๊ธฐ๋ก๋œ ์งˆ๋ฌธ์ด ์—†์Šต๋‹ˆ๋‹ค.'} + + + + + + {selectedEntry.answerText || '๊ธฐ๋ก๋œ ๋‹ต๋ณ€์ด ์—†์Šต๋‹ˆ๋‹ค.'} + + + + + {selectedEntry.changedFiles.length ? ( + + {selectedEntry.changedFiles.map((file) => ( + {file} + ))} + + ) : ( + ์ถ”์ถœ๋œ ๋ณ€๊ฒฝ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค. + )} + + + + {selectedEntry.currentSourceFiles.length ? ( + + {selectedEntry.currentSourceFiles.map((file) => ( + + {file} + + ))} + + ) : ( + ํ˜„์žฌ ์†Œ์Šค ๊ธฐ์ค€์œผ๋กœ ๋ฐ”๋กœ ์—ฐ๊ฒฐ๋˜๋Š” ํŒŒ์ผ์€ ์•„์ง ์ถ”์ถœ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. + )} + + + + {selectedEntry.diffBlocks.length ? ( + + {selectedEntry.diffBlocks.map((block, index) => ( +
+                          {block}
+                        
+ ))} +
+ ) : ( + ์ถ”์ถœ๋œ diff ๋ธ”๋ก์ด ์—†์Šต๋‹ˆ๋‹ค. ์„ค๋ช…๊ณผ ํŒŒ์ผ ๋ชฉ๋ก๋งŒ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. + )} +
+
+ ) : ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/app/main/ChatTypeManagementPage.css b/src/app/main/ChatTypeManagementPage.css new file mode 100755 index 0000000..28c2085 --- /dev/null +++ b/src/app/main/ChatTypeManagementPage.css @@ -0,0 +1,46 @@ +.chat-type-management-page { + height: 100%; +} + +.chat-type-management-page .ant-card, +.chat-type-management-page .ant-card-body, +.chat-type-management-page__card { + height: 100%; +} + +.chat-type-management-page__list, +.chat-type-management-page__editor { + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.chat-type-management-page__list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.chat-type-management-page__item { + cursor: pointer; + border: 1px solid #f0f0f0; + border-radius: 12px; + margin-bottom: 8px; + padding: 12px 16px; +} + +.chat-type-management-page__item--active { + border-color: #1677ff; + background: #f0f7ff; +} + +.chat-type-management-page__item-main { + width: 100%; +} + +.chat-type-management-page__item-description.ant-typography { + margin: 8px 0 10px; +} diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx new file mode 100755 index 0000000..3b74367 --- /dev/null +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -0,0 +1,290 @@ +import { ArrowLeftOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'; +import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Space, Switch, Tag, Typography } from 'antd'; +import { useEffect, useMemo, useState } from 'react'; +import { + canUseChatType, + CHAT_PERMISSION_ROLE_LABELS, + resolveCurrentChatPermissionRoles, + upsertChatType, + useChatTypeRegistry, + type ChatPermissionRole, + type ChatTypeRecord, +} from './chatTypeAccess'; +import { useTokenAccess } from './tokenAccess'; +import './ChatTypeManagementPage.css'; + +const { Paragraph, Text, Title } = Typography; + +type ChatTypeFormValue = { + id?: string; + name: string; + description: string; + isTemplate: boolean; + permissions: ChatPermissionRole[]; + enabled: boolean; +}; + +const EMPTY_FORM_VALUE: ChatTypeFormValue = { + name: '', + description: '', + isTemplate: false, + permissions: ['token-user'], + enabled: true, +}; + +function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { + if (!chatType) { + return EMPTY_FORM_VALUE; + } + + return { + id: chatType.id, + name: chatType.name, + description: chatType.description, + isTemplate: chatType.isTemplate, + permissions: chatType.permissions, + enabled: chatType.enabled, + }; +} + +export function ChatTypeManagementPage() { + const { hasAccess } = useTokenAccess(); + const { chatTypes, setChatTypes } = useChatTypeRegistry(); + const [selectedChatTypeId, setSelectedChatTypeId] = useState(chatTypes[0]?.id ?? null); + const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); + const [isCreating, setIsCreating] = useState(false); + const [form] = Form.useForm(); + const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); + + const selectedChatType = useMemo( + () => chatTypes.find((item) => item.id === selectedChatTypeId) ?? null, + [chatTypes, selectedChatTypeId], + ); + + useEffect(() => { + if (selectedChatTypeId && chatTypes.some((item) => item.id === selectedChatTypeId)) { + return; + } + + setSelectedChatTypeId(chatTypes[0]?.id ?? null); + }, [chatTypes, selectedChatTypeId]); + + useEffect(() => { + form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); + }, [form, isCreating, selectedChatType]); + + useEffect(() => { + if (detailMode !== 'detail') { + return; + } + + if (isCreating || selectedChatType) { + return; + } + + setDetailMode('list'); + }, [detailMode, isCreating, selectedChatType]); + + const openCreateForm = () => { + setIsCreating(true); + setSelectedChatTypeId(null); + setDetailMode('detail'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + }; + + const openDetail = (chatTypeId: string) => { + setIsCreating(false); + setSelectedChatTypeId(chatTypeId); + setDetailMode('detail'); + }; + + const closeDetail = () => { + setIsCreating(false); + setDetailMode('list'); + }; + + const handleDelete = () => { + if (!selectedChatType) { + return; + } + + if (!window.confirm(`"${selectedChatType.name}" ์ปจํ…์ŠคํŠธ๋ฅผ ์‚ญ์ œํ• ๊นŒ์š”?`)) { + return; + } + + const nextChatTypes = chatTypes.filter((item) => item.id !== selectedChatType.id); + setChatTypes(nextChatTypes); + setSelectedChatTypeId(nextChatTypes[0]?.id ?? null); + setIsCreating(false); + setDetailMode('list'); + form.resetFields(); + form.setFieldsValue(EMPTY_FORM_VALUE); + }; + + if (!hasAccess) { + return ( + + + + ); + } + + return ( +
+ {detailMode === 'list' ? ( + } onClick={openCreateForm}> + ์‹ ๊ทœ ์ปจํ…์ŠคํŠธ + + } + > +
+
+ ๋“ฑ๋ก ์ปจํ…์ŠคํŠธ + {chatTypes.length}๊ฑด +
+ + {chatTypes.length > 0 ? ( + { + const isCurrentUserAllowed = canUseChatType(item, userRoles); + const itemClassName = + item.id === selectedChatTypeId + ? 'chat-type-management-page__item chat-type-management-page__item--active' + : 'chat-type-management-page__item'; + + return ( + { + openDetail(item.id); + }} + actions={[ +
+
+ ) : ( + + {!isCreating && selectedChatType ? ( + + ) : null} + + + } + > +
+
+ {isCreating ? '์‹ ๊ทœ ์ปจํ…์ŠคํŠธ ๋“ฑ๋ก' : selectedChatType?.name ?? '์ปจํ…์ŠคํŠธ ์ˆ˜์ •'} + ์ด๋ฆ„๊ณผ ๊ธฐ๋ณธ ๋ฌธ๋งฅ ์„ค๋ช…, ๊ถŒํ•œ ๋Œ€์ƒ๋งŒ ๊ด€๋ฆฌํ•˜๋ฉด ์ฑ„ํŒ…์— ๊ทธ๋Œ€๋กœ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค. +
+ +
{ + const nextChatTypes = upsertChatType(chatTypes, values); + setChatTypes(nextChatTypes); + + const savedChatType = nextChatTypes.find((item) => item.id === values.id || item.name === values.name); + setIsCreating(false); + setSelectedChatTypeId(savedChatType?.id ?? null); + setDetailMode('detail'); + }} + > + + + + + + + + + + + + + + + + + + + + +
+
+
+ )} +
+ ); +} diff --git a/src/app/main/HeaderMessageCenter.css b/src/app/main/HeaderMessageCenter.css new file mode 100644 index 0000000..7b71d8b --- /dev/null +++ b/src/app/main/HeaderMessageCenter.css @@ -0,0 +1,165 @@ +.header-message-center__trigger.ant-btn { + width: 36px; + height: 36px; + border-radius: 12px; +} + +.header-message-center__summary { + display: flex; + flex-direction: column; + gap: 2px; +} + +.header-message-center__loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; +} + +.header-message-center__item { + padding: 0 !important; + border: none !important; +} + +.header-message-center__item + .header-message-center__item { + margin-top: 10px; +} + +.header-message-center__swipe { + position: relative; + overflow: hidden; + border-radius: 16px; + isolation: isolate; +} + +.header-message-center__delete-action { + position: absolute; + inset: 0 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 96px; + border: none; + border-radius: 16px; + background: linear-gradient(180deg, #ef4444 0%, #dc2626 100%); + color: #ffffff; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; +} + +.header-message-center__swipe.is-swiped .header-message-center__delete-action, +.header-message-center__swipe.is-dragging .header-message-center__delete-action { + opacity: 1; + visibility: visible; +} + +.header-message-center__item-button { + display: block; + position: relative; + z-index: 1; + width: 100%; + min-width: 0; + overflow: hidden; + padding: 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 16px; + background: #ffffff; + text-align: left; + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + touch-action: pan-y; + will-change: transform; +} + +.header-message-center__item-button:hover { + border-color: rgba(37, 99, 235, 0.32); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); +} + +.header-message-center__item.is-unread .header-message-center__item-button { + background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%); + border-color: rgba(37, 99, 235, 0.24); +} + +.header-message-center__item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-width: 0; + margin-bottom: 10px; +} + +.header-message-center__item-time.ant-typography { + flex: 0 0 auto; +} + +.header-message-center__item-title.ant-typography { + margin-bottom: 8px; + overflow-wrap: anywhere; + word-break: break-word; +} + +.header-message-center__item-preview.ant-typography { + margin-bottom: 0; + color: #475467; + overflow-wrap: anywhere; + word-break: break-word; +} + +.header-message-center__swipe-hint { + display: none; +} + +.header-message-center__detail-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.header-message-center__detail-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.header-message-center__detail-body.ant-typography { + margin-bottom: 0; + white-space: pre-wrap; +} + +@media (max-width: 768px) { + .header-message-center__item-button { + padding: 12px 12px 34px; + } + + .header-message-center__swipe-hint { + position: absolute; + right: 14px; + bottom: 10px; + display: block; + color: #94a3b8; + font-size: 0.72rem; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .header-message-center__swipe.is-swiped .header-message-center__swipe-hint, + .header-message-center__swipe.is-dragging .header-message-center__swipe-hint { + opacity: 0; + } + + .header-message-center__item-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/app/main/HeaderMessageCenter.tsx b/src/app/main/HeaderMessageCenter.tsx new file mode 100644 index 0000000..c5c8021 --- /dev/null +++ b/src/app/main/HeaderMessageCenter.tsx @@ -0,0 +1,509 @@ +import { BellOutlined, ReloadOutlined } from '@ant-design/icons'; +import { Alert, Badge, Button, Drawer, Empty, List, Modal, Space, Spin, Tag, Typography } from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + type NotificationMessageItem, + type NotificationMessagePriority, +} from './notificationApi'; +import { useNotificationController } from './chatV2/hooks/useNotificationController'; +import './HeaderMessageCenter.css'; + +const { Paragraph, Text, Title } = Typography; +const SWIPE_DELETE_THRESHOLD_PX = 88; +const SWIPE_DELETE_LIMIT_PX = 132; +const TOUCH_TAP_SLOP_PX = 10; + +function getNotificationLinkMetadata(metadata: Record | null | undefined) { + if (!metadata || typeof metadata !== 'object') { + return null; + } + + const linkUrl = typeof metadata.linkUrl === 'string' ? metadata.linkUrl.trim() : ''; + const linkLabel = typeof metadata.linkLabel === 'string' ? metadata.linkLabel.trim() : ''; + + if (!linkUrl) { + return null; + } + + return { + linkUrl, + linkLabel: linkLabel || '๋ฐ”๋กœ๊ฐ€๊ธฐ', + }; +} + +function formatDateTime(value: string | null) { + if (!value) { + return '-'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +function getPriorityTagColor(priority: NotificationMessagePriority) { + switch (priority) { + case 'urgent': + return 'red'; + case 'high': + return 'volcano'; + case 'low': + return 'default'; + default: + return 'blue'; + } +} + +function getPriorityLabel(priority: NotificationMessagePriority) { + switch (priority) { + case 'urgent': + return '๊ธด๊ธ‰'; + case 'high': + return '๋†’์Œ'; + case 'low': + return '๋‚ฎ์Œ'; + default: + return '๋ณดํ†ต'; + } +} + +function getMetadataText(metadata: Record | null | undefined, key: string) { + if (!metadata || typeof metadata !== 'object') { + return ''; + } + + const value = metadata[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function getFirstMetadataText(metadata: Record | null | undefined, keys: string[]) { + for (const key of keys) { + const value = getMetadataText(metadata, key); + + if (value) { + return value; + } + } + + return ''; +} + +function extractChatDetailSections(message: NotificationMessageItem | null) { + if (!message || message.category !== 'chat') { + return null; + } + + const questionText = getFirstMetadataText(message.metadata, ['questionText', 'userText', 'requestText', 'question']); + const answerText = getFirstMetadataText(message.metadata, ['answerText', 'responseText', 'replyText', 'answer']); + + if (questionText || answerText) { + return { questionText, answerText }; + } + + const bodyLines = String(message.body ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const questionLine = bodyLines.find((line) => line.startsWith('์งˆ๋ฌธ:')); + const answerLine = bodyLines.find((line) => line.startsWith('๋‹ต๋ณ€:')); + + const parsedQuestionText = questionLine ? questionLine.replace(/^์งˆ๋ฌธ:\s*/, '').trim() : ''; + const parsedAnswerText = answerLine ? answerLine.replace(/^๋‹ต๋ณ€:\s*/, '').trim() : ''; + + if (!parsedQuestionText && !parsedAnswerText) { + return null; + } + + return { + questionText: parsedQuestionText, + answerText: parsedAnswerText, + }; +} + +export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: boolean }) { + const navigate = useNavigate(); + const swipeStartXRef = useRef(null); + const swipeStartYRef = useRef(null); + const swipeLockedIdRef = useRef(null); + const swipeMovedRef = useRef(false); + const touchScrollDetectedRef = useRef(false); + const suppressClickRef = useRef(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [swipedMessageId, setSwipedMessageId] = useState(null); + const [draggingMessageId, setDraggingMessageId] = useState(null); + const [dragOffsetX, setDragOffsetX] = useState(0); + const { + unreadCount, + detailOpen, + setDetailOpen, + messages, + selectedMessage, + listLoading, + detailLoading, + toggleReadLoading, + deletingMessageId, + listError, + detailError, + loadMessages, + openMessageDetail, + handleToggleReadState, + handleDeleteMessage: deleteMessage, + } = useNotificationController(drawerOpen); + + const resetSwipeState = () => { + swipeStartXRef.current = null; + swipeStartYRef.current = null; + swipeLockedIdRef.current = null; + swipeMovedRef.current = false; + touchScrollDetectedRef.current = false; + setDraggingMessageId(null); + setDragOffsetX(0); + }; + + const handleDeleteMessage = async (id: number) => { + try { + await deleteMessage(id); + setSwipedMessageId((current) => (current === id ? null : current)); + } finally { + resetSwipeState(); + } + }; + + const handleSwipeStart = (id: number, clientX: number, clientY: number) => { + if (deletingMessageId === id) { + return; + } + + swipeStartXRef.current = clientX; + swipeStartYRef.current = clientY; + swipeLockedIdRef.current = id; + swipeMovedRef.current = false; + touchScrollDetectedRef.current = false; + setDraggingMessageId(id); + setDragOffsetX(0); + setSwipedMessageId((current) => (current === id ? id : null)); + }; + + const handleSwipeMove = (id: number, clientX: number, clientY: number) => { + if (swipeLockedIdRef.current !== id || swipeStartXRef.current === null || swipeStartYRef.current === null) { + return; + } + + const deltaX = clientX - swipeStartXRef.current; + const deltaY = clientY - swipeStartYRef.current; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + if (absDeltaY > TOUCH_TAP_SLOP_PX && absDeltaY > absDeltaX) { + touchScrollDetectedRef.current = true; + setSwipedMessageId(null); + setDraggingMessageId(null); + setDragOffsetX(0); + return; + } + + const nextOffset = deltaX >= 0 ? 0 : Math.max(deltaX, -SWIPE_DELETE_LIMIT_PX); + if (Math.abs(nextOffset) >= 12) { + swipeMovedRef.current = true; + suppressClickRef.current = true; + } + setDraggingMessageId(id); + setDragOffsetX(nextOffset); + }; + + const handleSwipeEnd = (id: number) => { + if (swipeLockedIdRef.current !== id) { + return false; + } + + if (touchScrollDetectedRef.current) { + resetSwipeState(); + setSwipedMessageId(null); + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + return false; + } + + const shouldDelete = dragOffsetX <= -SWIPE_DELETE_THRESHOLD_PX; + const wasSwipeGesture = swipeMovedRef.current; + resetSwipeState(); + + if (shouldDelete) { + void handleDeleteMessage(id); + return false; + } + + setSwipedMessageId(null); + window.setTimeout(() => { + suppressClickRef.current = false; + }, 0); + return !wasSwipeGesture; + }; + + const handleActivateMessage = (id: number) => { + if (suppressClickRef.current || swipeMovedRef.current) { + suppressClickRef.current = false; + return; + } + + if (draggingMessageId === id || swipedMessageId === id) { + setSwipedMessageId(null); + resetSwipeState(); + return; + } + + void openMessageDetail(id); + }; + + const handleOpenLinkedPage = () => { + const link = getNotificationLinkMetadata(selectedMessage?.metadata); + + if (!link || typeof window === 'undefined') { + return; + } + + try { + const url = new URL(link.linkUrl, window.location.origin); + + if (url.origin === window.location.origin) { + navigate(`${url.pathname}${url.search}${url.hash}`); + setDetailOpen(false); + setDrawerOpen(false); + return; + } + } catch { + // Fall through to full navigation for invalid or external urls. + } + + window.location.assign(link.linkUrl); + }; + + useEffect(() => { + if (!drawerOpen) { + resetSwipeState(); + setSwipedMessageId(null); + } + }, [drawerOpen]); + + const selectedMessageLink = getNotificationLinkMetadata(selectedMessage?.metadata); + const selectedChatDetail = extractChatDetailSections(selectedMessage); + + return ( + <> + + + +
์™ผ์ชฝ์œผ๋กœ ๋ฐ€์–ด ์‚ญ์ œ
+
+ + )} + /> + ) : ( + + )} + + + + { + setDetailOpen(false); + }} + footer={[ + , + , + , + ]} + > + {detailError ? : null} + + {detailLoading ? ( +
+ +
+ ) : selectedMessage ? ( + + + + {getPriorityLabel(selectedMessage.priority)} + + {selectedMessage.category} + {selectedMessage.source} + + {selectedMessage.read ? '์ฝ์Œ' : '์•ˆ์ฝ์Œ'} + + +
+ ์ˆ˜์‹  ์‹œ๊ฐ {formatDateTime(selectedMessage.createdAt)} + ํ™•์ธ ์‹œ๊ฐ {formatDateTime(selectedMessage.readAt)} +
+ + {selectedMessage.title} + + {selectedChatDetail ? ( + + {selectedChatDetail.questionText ? ( +
+ ์š”์ฒญ๋‚ด์šฉ + + {selectedChatDetail.questionText} + +
+ ) : null} + {selectedChatDetail.answerText ? ( +
+ ๋‹ต๋ณ€ + + {selectedChatDetail.answerText} + +
+ ) : null} +
+ ) : ( + + {selectedMessage.body} + + )} +
+ ) : ( + + )} +
+ + ); +} diff --git a/src/app/main/InitialLoadingOverlay.css b/src/app/main/InitialLoadingOverlay.css new file mode 100755 index 0000000..adbbf78 --- /dev/null +++ b/src/app/main/InitialLoadingOverlay.css @@ -0,0 +1,144 @@ +.app-loading-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient(circle at top, rgba(59, 130, 246, 0.24), transparent 30%), + linear-gradient(135deg, rgba(2, 6, 23, 0.96), rgba(15, 23, 42, 0.98)); + animation: app-loading-overlay-fade 0.45s ease-out 1.45s forwards; +} + +.app-loading-panel { + width: min(100%, 560px); + padding: 28px; + border: 1px solid rgba(96, 165, 250, 0.22); + border-radius: 28px; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96)), + rgba(15, 23, 42, 0.92); + box-shadow: + 0 24px 80px rgba(15, 23, 42, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + backdrop-filter: blur(16px); +} + +.app-loading-panel__eyebrow { + display: inline-flex; + margin-bottom: 10px; + color: rgba(147, 197, 253, 0.92); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.24em; +} + +.app-loading-panel__title { + display: block; + margin-bottom: 18px; + color: #f8fafc; + font-size: clamp(26px, 5vw, 40px); + letter-spacing: 0.08em; +} + +.app-loading-panel__status { + display: inline-flex; + align-items: center; + gap: 10px; + margin-bottom: 22px; + color: rgba(191, 219, 254, 0.88); + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.app-loading-panel__pulse { + width: 10px; + height: 10px; + border-radius: 999px; + background: #38bdf8; + box-shadow: 0 0 0 rgba(56, 189, 248, 0.5); + animation: app-loading-pulse 1.2s ease-out infinite; +} + +.app-loading-log { + display: grid; + gap: 10px; +} + +.app-loading-log__line { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 14px; + padding: 12px 14px; + border: 1px solid rgba(96, 165, 250, 0.14); + border-radius: 16px; + background: rgba(15, 23, 42, 0.58); + color: #dbeafe; + opacity: 0; + transform: translateY(8px); + animation: app-loading-log-in 0.55s ease-out forwards; +} + +.app-loading-log__time { + color: rgba(125, 211, 252, 0.72); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.app-loading-log__text { + min-width: 0; + font-size: 14px; + letter-spacing: 0.03em; +} + +@keyframes app-loading-log-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes app-loading-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.46); + } + + 70% { + box-shadow: 0 0 0 12px rgba(56, 189, 248, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(56, 189, 248, 0); + } +} + +@keyframes app-loading-overlay-fade { + from { + opacity: 1; + } + + to { + opacity: 0; + visibility: hidden; + } +} + +@media (max-width: 640px) { + .app-loading-panel { + padding: 22px 18px; + border-radius: 24px; + } + + .app-loading-log__line { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/src/app/main/InitialLoadingOverlay.tsx b/src/app/main/InitialLoadingOverlay.tsx new file mode 100755 index 0000000..0aac6a6 --- /dev/null +++ b/src/app/main/InitialLoadingOverlay.tsx @@ -0,0 +1,36 @@ +import './InitialLoadingOverlay.css'; + +const INITIAL_LOADING_LOGS = [ + 'BOOT SEQUENCE :: app shell warmup', + 'CONFIG SYNC :: workspace profile applied', + 'SESSION LINK :: reconnecting realtime channel', + 'MODULE CHECK :: dashboard widgets online', + 'READY SIGNAL :: rendering main viewport', +]; + +export function InitialLoadingOverlay() { + return ( + + ); +} diff --git a/src/app/main/MainChatPanel.css b/src/app/main/MainChatPanel.css new file mode 100755 index 0000000..5dff55f --- /dev/null +++ b/src/app/main/MainChatPanel.css @@ -0,0 +1,752 @@ +.app-chat-panel { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + height: calc(100dvh - 128px); + max-height: calc(100dvh - 128px); + background: + radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 30%), + radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.12), transparent 34%), + linear-gradient(180deg, rgba(241, 247, 255, 0.98), rgba(230, 240, 252, 0.94)); +} + +.app-chat-panel .ant-card-body { + display: flex; + flex: 1; + min-height: 0; + padding: 16px; +} + +.app-chat-panel__stack { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + width: 100%; +} + +.app-chat-panel__view-switcher { + display: flex; + gap: 8px; + align-items: center; +} + +.app-chat-panel__view-switcher .ant-btn { + border-radius: 999px; +} + +.app-chat-panel__meta { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(148, 163, 184, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); +} + +.app-chat-panel__meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.app-chat-panel__meta-row--secondary { + flex-wrap: wrap; +} + +.app-chat-panel__meta-tag.ant-tag { + margin-inline-end: 0; + padding-inline: 10px; + border-radius: 999px; + background: rgba(248, 250, 252, 0.92); + border: 1px solid rgba(191, 219, 254, 0.8); + color: #1e3a8a; +} + +.app-chat-panel__connection-dot { + display: inline-flex; + width: 12px; + height: 12px; + border-radius: 999px; + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.65); +} + +.app-chat-panel__connection-dot--connected { + background: #2563eb; +} + +.app-chat-panel__connection-dot--disconnected { + background: #dc2626; +} + +.app-chat-panel__status-copy { + flex: 1; +} + +.app-chat-panel__messages { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + padding: 4px 2px; + overflow-y: auto; + scrollbar-width: none; +} + +.app-chat-panel__messages::-webkit-scrollbar { + display: none; +} + +.app-chat-message { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + border-radius: 18px; + max-width: min(78%, 720px); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); +} + +.app-chat-message--codex { + align-self: flex-start; + background: linear-gradient(180deg, #e8f1ff, #f4f8ff); + border: 1px solid rgba(37, 99, 235, 0.16); +} + +.app-chat-message--system { + align-self: center; + max-width: min(92%, 760px); + background: linear-gradient(180deg, #f8fafc, #f1f5f9); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.app-chat-message--user { + align-self: flex-end; + background: linear-gradient(180deg, #e9fff3, #f3fff8); + border: 1px solid rgba(22, 163, 74, 0.18); +} + +.app-chat-message__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.app-chat-message__header-meta { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.app-chat-message__header-meta .ant-btn { + color: rgba(71, 85, 105, 0.88); +} + +.app-chat-message__body { + margin: 0; + white-space: pre-wrap; +} + +.app-chat-panel__composer { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; + padding: 12px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(148, 163, 184, 0.2); + box-shadow: 0 18px 36px rgba(148, 163, 184, 0.16); +} + +.app-chat-panel__composer-type { + width: 100%; +} + +.app-chat-panel__composer-type .ant-select { + width: 100%; +} + +.app-chat-panel__composer .ant-input-textarea { + flex: 1; + min-width: 240px; +} + +.app-chat-panel__composer .ant-btn { + flex: none; + height: 44px; + padding-inline: 18px; + border-radius: 16px; +} + +.app-chat-panel__error-layout { + position: relative; + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.app-chat-panel__error-detail-screen { + position: absolute; + inset: 0; + z-index: 4; + padding: 0; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98)); +} + +.app-chat-panel__error-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.app-chat-panel__error-toolbar-copy { + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-chat-panel__error-loading, +.app-chat-panel__error-empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; +} + +.app-chat-panel__error-content { + display: grid; + grid-template-columns: minmax(260px, 300px) minmax(0, 1fr); + gap: 14px; + flex: 1; + min-height: 0; + min-width: 0; +} + +.app-chat-panel__error-list, +.app-chat-panel__error-detail { + min-height: 0; + overflow-y: auto; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); +} + +.app-chat-panel__error-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; + max-height: min(78vh, 1120px); +} + +.app-chat-panel__error-item { + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; + padding: 9px 10px; + text-align: left; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.96)); + cursor: pointer; +} + +.app-chat-panel__error-item-title { + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.app-chat-panel__error-item--active { + border-color: rgba(37, 99, 235, 0.4); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.18); +} + +.app-chat-panel__error-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; +} + +.app-chat-panel__error-item-message.ant-typography { + margin: 0; + color: #475569; + display: -webkit-box; + overflow: hidden; + font-size: 11px; + line-height: 1.35; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.app-chat-panel__error-item-badges { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.app-chat-panel__error-item-badges .ant-tag { + margin-inline-end: 0; + padding-inline: 6px; + font-size: 10px; + line-height: 18px; +} + +.app-chat-panel__error-detail { + display: flex; + position: relative; + flex-direction: column; + gap: 14px; + min-width: 0; + padding: 20px 24px 24px; +} + +.app-chat-panel__error-detail-actions { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 6px; + position: absolute; + top: 20px; + right: 24px; + z-index: 2; +} + +.app-chat-panel__error-detail-actions .ant-btn { + flex: 0 0 auto; +} + +.app-chat-panel__error-reference-stage { + display: grid; + grid-template-columns: minmax(220px, 240px) minmax(0, 1fr); + gap: 12px; +} + +.app-chat-panel__error-reference-rail { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 720px; + padding-right: 2px; + overflow-y: auto; +} + +.app-chat-panel__error-reference-item { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + padding: 10px 12px; + text-align: left; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 14px; + background: rgba(248, 250, 252, 0.92); + cursor: pointer; +} + +.app-chat-panel__error-reference-item-snippet.ant-typography { + margin: 0; + color: #64748b; + font-size: 11px; + line-height: 1.45; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.app-chat-panel__error-reference-item--active { + border-color: rgba(37, 99, 235, 0.42); + background: linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.98)); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.16); +} + +.app-chat-panel__error-reference-item-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.app-chat-panel__error-reference-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + padding: 18px; + border-radius: 20px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.92); +} + +.app-chat-panel__error-reference-main-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.app-chat-panel__error-reference-main-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.app-chat-panel__error-reference-main-meta-item { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(248, 250, 252, 0.9); +} + +.app-chat-panel__error-reference-main-meta-item .ant-typography:last-child { + word-break: break-word; +} + +.app-chat-panel__error-reference-main-meta-item--wide { + grid-column: 1 / -1; +} + +.app-chat-panel__error-detail-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + min-height: 32px; + padding-right: 48px; +} + +.app-chat-panel__error-detail-header-meta { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 6px; +} + +.app-chat-panel__error-detail-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.app-chat-panel__error-summary-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.app-chat-panel__error-summary-pill { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.96)); +} + +.app-chat-panel__error-detail-meta-row { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border-radius: 14px; + background: rgba(241, 245, 249, 0.9); + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.app-chat-panel__error-detail-line.ant-typography { + margin: 0; +} + +.app-chat-panel__error-tabs .ant-tabs-content-holder, +.app-chat-panel__error-tabs .ant-tabs-content, +.app-chat-panel__error-tabs .ant-tabs-tabpane { + min-height: 0; +} + +.app-chat-panel__error-tab-stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.app-chat-panel__error-tabs .previewer-ui { + height: auto; +} + +.app-chat-panel__error-tabs .previewer-ui__body.previewer-ui__scroll { + height: auto !important; + max-height: none !important; + overflow: visible !important; +} + +.app-chat-panel__error-detail-modal .ant-modal { + max-width: calc(100vw - 32px); +} + +.app-chat-panel__error-detail-modal .ant-modal-content { + padding: 20px; +} + +.app-chat-panel__error-detail--modal { + padding: 0; +} + +.app-chat-panel__error-detail--expanded { + height: 100%; + border-radius: 22px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)); +} + +.app-chat-panel__error-reference-list { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(248, 250, 252, 0.88); +} + +.app-chat-panel__error-image-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.app-chat-panel__error-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +.app-chat-panel__error-preview-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.92); +} + +.app-chat-panel__error-preview-card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.app-chat-panel__error-preview-frame-wrap { + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: linear-gradient(180deg, rgba(241, 245, 249, 0.8), rgba(255, 255, 255, 0.96)); +} + +.app-chat-panel__error-preview-frame { + width: 100%; + height: 460px; + border: 0; + background: #ffffff; +} + +.app-chat-panel__error-preview-card--stage { + padding: 0; + border: 0; + background: transparent; +} + +.app-chat-panel__error-preview-frame--stage { + height: min(78vh, 980px); +} + +.app-chat-panel__error-preview-url.ant-typography { + margin: 0; + word-break: break-all; +} + +.app-chat-panel__error-source-preview { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(248, 250, 252, 0.82); +} + +.app-chat-panel__error-source-preview pre { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.55; + color: #334155; + font-family: + 'JetBrains Mono', 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; +} + +@media (max-width: 1280px) { + .app-chat-panel__error-content { + grid-template-columns: minmax(240px, 280px) minmax(0, 1fr); + } + + .app-chat-panel__error-reference-stage { + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + } +} + +@media (max-width: 960px) { + .app-chat-panel__error-content, + .app-chat-panel__error-reference-stage, + .app-chat-panel__error-reference-main-meta, + .app-chat-panel__error-summary-strip, + .app-chat-panel__error-detail-meta { + grid-template-columns: minmax(0, 1fr); + } + + .app-chat-panel__error-list { + max-height: none; + } + + .app-chat-panel__error-preview-frame--stage { + height: min(62vh, 720px); + } +} + +.app-chat-panel__error-detail-block { + display: flex; + flex-direction: column; + gap: 8px; +} + +.app-chat-panel__error-detail-block pre { + margin: 0; + padding: 12px; + overflow: auto; + border-radius: 14px; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + line-height: 1.5; +} + +@media (max-width: 1080px) { + .app-chat-panel { + position: static; + height: calc(100dvh - 112px); + max-height: calc(100dvh - 112px); + } +} + +@media (max-width: 768px) { + .app-chat-panel { + height: calc(100dvh - 76px); + max-height: calc(100dvh - 76px); + border-radius: 0; + } + + .app-chat-panel.ant-card .ant-card-head { + padding: 0 16px; + } + + .app-chat-panel.ant-card .ant-card-body { + padding: 12px; + } + + .app-chat-message { + max-width: 92%; + } + + .app-chat-panel__meta-row { + align-items: flex-start; + flex-direction: column; + } + + .app-chat-message__header { + flex-direction: column; + align-items: flex-start; + } + + .app-chat-panel__error-toolbar, + .app-chat-panel__error-item-top, + .app-chat-panel__error-reference-main-top, + .app-chat-panel__view-switcher { + flex-direction: column; + align-items: flex-start; + } + + .app-chat-panel__error-detail-header { + align-items: flex-start; + padding-right: 52px; + } + + .app-chat-panel__error-content { + grid-template-columns: minmax(0, 1fr); + } + + .app-chat-panel__error-detail-meta { + grid-template-columns: minmax(0, 1fr); + } + + .app-chat-panel__error-reference-stage { + grid-template-columns: minmax(0, 1fr); + } + + .app-chat-panel__error-summary-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .app-chat-panel__error-detail-actions { + top: 16px; + right: 16px; + } + + .app-chat-panel__error-detail-actions .ant-btn { + width: auto; + } + + .app-chat-panel__error-preview-grid { + grid-template-columns: 1fr; + } + + .app-chat-panel__error-preview-frame { + height: 320px; + } + + .app-chat-panel__error-preview-frame--stage { + height: 420px; + } + + .app-chat-panel__composer { + align-items: stretch; + flex-direction: column; + } +} diff --git a/src/app/main/MainChatPanel.hotfix.css b/src/app/main/MainChatPanel.hotfix.css new file mode 100644 index 0000000..28bcb2e --- /dev/null +++ b/src/app/main/MainChatPanel.hotfix.css @@ -0,0 +1,2723 @@ +.app-chat-panel { + display: flex; + flex-direction: column; + align-self: stretch; + height: 100%; + max-height: none; + min-height: 0; + overflow: hidden; + background: + linear-gradient(180deg, rgba(243, 246, 252, 0.98), rgba(235, 240, 248, 0.98)), + radial-gradient(circle at top left, rgba(59, 130, 246, 0.1), transparent 24%); +} + +.app-chat-panel--maximized { + position: fixed; + inset: 12px; + z-index: 1200; + height: auto; + max-height: none; + margin: 0; +} + +.app-chat-panel--tablet-app { + width: 100%; + max-width: 100%; + margin-inline: 0; + border-radius: 0; + overflow: hidden; +} + +.app-chat-panel__preview-modal.ant-modal { + z-index: 1400; +} + +.app-chat-panel__preview-modal.ant-modal .ant-modal-mask { + z-index: 1399; +} + +.app-chat-panel .ant-card-head { + flex: 0 0 auto; + position: relative; + z-index: 4; + overflow: visible; + background: rgba(247, 249, 252, 0.82); + border-bottom: 1px solid rgba(148, 163, 184, 0.18); +} + +.app-chat-panel .ant-card-head-title { + padding: 10px 0; + overflow: visible; + min-width: 0; +} + +.app-chat-panel .ant-card-extra { + padding: 8px 0; + flex-shrink: 0; +} + +.app-chat-panel .ant-card-extra .ant-space { + gap: 4px !important; + flex-wrap: nowrap; +} + +.app-chat-panel .ant-card-extra .ant-btn, +.app-chat-panel__meta .ant-btn { + height: 30px; + padding-inline: 10px; + border-radius: 10px; + font-size: 12px; +} + +.app-chat-panel .ant-card-extra .ant-btn-icon-only, +.app-chat-panel__meta .ant-btn-icon-only, +.app-chat-message__expand.ant-btn-icon-only, +.app-chat-panel__composer-action-buttons .ant-btn-icon-only { + width: 30px; + min-width: 30px; + padding-inline: 0; +} + +.app-chat-panel .ant-card-body { + display: flex; + flex: 1 1 auto; + min-height: 0; + height: auto; + padding: 6px; + overflow: hidden; +} + +.app-chat-panel--maximized .ant-card-body { + overflow: hidden; +} + +.app-chat-panel__stack { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + height: 100%; +} + +.app-chat-panel__conversation-shell { + position: relative; + display: flex; + flex-direction: row; + flex: 1; + min-height: 0; + min-width: 0; + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08); + overflow: hidden; +} + +.app-chat-panel__conversation-list { + display: flex; + flex-direction: column; + width: 220px; + min-width: 220px; + min-height: 0; + border-right: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(248, 250, 252, 0.72); +} + +.app-chat-panel__conversation-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.app-chat-panel__conversation-list-search { + padding: 8px 8px 0; +} + +.app-chat-panel__conversation-list-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 6px; + min-height: 0; + padding: 8px; + overflow-y: auto; +} + +.app-chat-panel__conversation-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.app-chat-panel__conversation-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 2px 2px 0; +} + +.app-chat-panel__conversation-section-header--muted { + margin-top: 6px; +} + +.app-chat-panel__conversation-section-header--processing .app-chat-panel__conversation-section-title { + color: #b45309; +} + +.app-chat-panel__conversation-section-header--processing .app-chat-panel__conversation-section-count { + background: rgba(253, 230, 138, 0.92); + color: #92400e; +} + +.app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-title { + color: #1d4ed8; +} + +.app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-count { + background: linear-gradient(180deg, rgba(191, 219, 254, 0.98), rgba(147, 197, 253, 0.96)); + color: #1e3a8a; + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.16); +} + +.app-chat-panel__conversation-section-title { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.01em; + color: #1d4ed8; +} + +.app-chat-panel__conversation-section-header--muted .app-chat-panel__conversation-section-title { + color: #64748b; +} + +.app-chat-panel__conversation-section-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 6px; + border-radius: 999px; + background: rgba(191, 219, 254, 0.9); + color: #1e40af; + font-size: 11px; + font-weight: 700; +} + +.app-chat-panel__conversation-section-header--muted .app-chat-panel__conversation-section-count { + background: rgba(226, 232, 240, 0.88); + color: #475569; +} + +.app-chat-panel__conversation-item { + position: relative; + display: flex; + align-items: flex-start; + gap: 6px; + min-width: 0; + padding: 0; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(255, 255, 255, 0.82); + border-radius: 14px; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease; +} + +.app-chat-panel__conversation-item--active { + border-color: rgba(59, 130, 246, 0.35); + background: rgba(239, 246, 255, 0.95); + box-shadow: 0 10px 22px rgba(59, 130, 246, 0.08); +} + +.app-chat-panel__conversation-item--processing { + border-color: rgba(245, 158, 11, 0.34); + background: + linear-gradient(90deg, rgba(255, 247, 237, 0.98), rgba(255, 251, 235, 0.98) 32%, rgba(255, 255, 255, 0.99) 72%), + #fff; + box-shadow: + inset 4px 0 0 rgba(245, 158, 11, 0.92), + 0 10px 24px rgba(245, 158, 11, 0.12); +} + +.app-chat-panel__conversation-item--unread { + border-color: rgba(37, 99, 235, 0.7); + background: + linear-gradient(90deg, rgba(147, 197, 253, 0.98), rgba(219, 234, 254, 0.98) 22%, rgba(239, 246, 255, 0.98) 46%, rgba(255, 255, 255, 0.99) 68%), + #fff; + box-shadow: + inset 4px 0 0 rgba(37, 99, 235, 1), + 0 10px 24px rgba(37, 99, 235, 0.18); +} + +.app-chat-panel__conversation-item--unread-section { + border-color: rgba(37, 99, 235, 0.82); + background: + linear-gradient(135deg, rgba(219, 234, 254, 1), rgba(239, 246, 255, 0.99) 40%, rgba(255, 255, 255, 1) 82%), + #fff; + box-shadow: + inset 6px 0 0 rgba(37, 99, 235, 0.96), + 0 14px 30px rgba(37, 99, 235, 0.2); +} + +.app-chat-panel__conversation-item--general { + opacity: 0.94; +} + +.app-chat-panel__conversation-item--unread::after { + content: ''; + position: absolute; + top: 10px; + right: 10px; + width: 10px; + height: 10px; + border-radius: 999px; + background: #2563eb; + box-shadow: + 0 0 0 4px rgba(191, 219, 254, 0.9), + 0 0 12px rgba(37, 99, 235, 0.45); +} + +.app-chat-panel__conversation-item:hover { + transform: translateY(-1px); + box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08); +} + +.app-chat-panel__conversation-item--unread:hover { + box-shadow: + inset 4px 0 0 rgba(37, 99, 235, 1), + 0 14px 30px rgba(37, 99, 235, 0.18); +} + +.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { + border-color: rgba(29, 78, 216, 0.78); + background: + linear-gradient(90deg, rgba(147, 197, 253, 1), rgba(191, 219, 254, 0.99) 24%, rgba(219, 234, 254, 0.99) 46%, rgba(239, 246, 255, 1) 70%), + #fff; + box-shadow: + inset 4px 0 0 rgba(29, 78, 216, 1), + 0 12px 28px rgba(37, 99, 235, 0.22); +} + +.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section { + border-color: rgba(29, 78, 216, 0.92); + background: + linear-gradient(135deg, rgba(191, 219, 254, 1), rgba(219, 234, 254, 1) 34%, rgba(239, 246, 255, 1) 66%, rgba(255, 255, 255, 1) 88%), + #fff; + box-shadow: + inset 6px 0 0 rgba(29, 78, 216, 1), + 0 16px 34px rgba(37, 99, 235, 0.24); +} + +.app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--processing { + border-color: rgba(217, 119, 6, 0.58); + background: + linear-gradient(90deg, rgba(255, 237, 213, 1), rgba(254, 243, 199, 0.99) 26%, rgba(255, 251, 235, 0.99) 52%, rgba(255, 255, 255, 1) 74%), + #fff; + box-shadow: + inset 4px 0 0 rgba(217, 119, 6, 0.98), + 0 12px 28px rgba(217, 119, 6, 0.16); +} + +.app-chat-panel__conversation-item--processing.app-chat-panel__conversation-item--unread { + border-color: rgba(147, 51, 234, 0.48); + background: + linear-gradient(90deg, rgba(237, 233, 254, 0.98), rgba(219, 234, 254, 0.98) 24%, rgba(255, 251, 235, 0.98) 56%, rgba(255, 255, 255, 0.99) 76%), + #fff; + box-shadow: + inset 4px 0 0 rgba(124, 58, 237, 0.94), + 0 12px 28px rgba(124, 58, 237, 0.16); +} + +.app-chat-panel__conversation-item-main { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 8px 9px; + background: transparent; + text-align: left; + cursor: pointer; +} + +.app-chat-panel__conversation-item-heading { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} + +.app-chat-panel__conversation-item-title-wrap { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; + border-radius: 10px; +} + +.app-chat-panel__conversation-item-title, +.app-chat-panel__conversation-item-id, +.app-chat-panel__conversation-item-preview, +.app-chat-panel__conversation-item-time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-panel__conversation-item-title { + flex: 1; + min-width: 0; + font-size: 12px; + font-weight: 600; + color: #0f172a; +} + +.app-chat-panel__conversation-item-unread-dot { + width: 10px; + height: 10px; + flex: none; + border-radius: 999px; + background: #2563eb; + box-shadow: + 0 0 0 4px rgba(37, 99, 235, 0.16), + 0 0 10px rgba(37, 99, 235, 0.28); + margin-top: 1px; +} + +.app-chat-panel__conversation-item-unread-badge { + display: inline-flex; + align-items: center; + max-width: 92px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid rgba(29, 78, 216, 0.3); + background: linear-gradient(180deg, rgba(219, 234, 254, 1), rgba(191, 219, 254, 0.96)); + color: #1d4ed8; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.01em; + line-height: 1.2; + white-space: nowrap; + flex: none; + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.14); +} + +.app-chat-panel__conversation-item-time { + flex: none; + font-size: 10px; + color: #94a3b8; + font-weight: 600; +} + +.app-chat-panel__conversation-item-id { + font-size: 10px; + color: #94a3b8; +} + +.app-chat-panel__conversation-item-preview { + font-size: 11px; + color: #64748b; +} + +.app-chat-panel__conversation-item-flags { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title, +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time, +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-id { + color: #1d4ed8; +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title-wrap { + padding: 2px 6px 2px 0; + background: linear-gradient(90deg, rgba(219, 234, 254, 0.92), rgba(219, 234, 254, 0)); +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title { + font-weight: 700; +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-preview { + color: #1e3a8a; + font-weight: 600; +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time { + background: rgba(219, 234, 254, 0.9); + padding: 2px 6px; + border-radius: 999px; +} + +.app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-id { + color: #2563eb; +} + +.app-chat-panel__conversation-item-status { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + padding: 2px 6px; + border-radius: 999px; + font-weight: 700; + font-size: 10px; + line-height: 1.3; + color: #0f172a; + background: rgba(226, 232, 240, 0.72); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-chat-panel__conversation-item-status--queued { + color: #92400e; + background: rgba(253, 230, 138, 0.56); +} + +.app-chat-panel__conversation-item-status--started { + color: #1d4ed8; + background: rgba(191, 219, 254, 0.62); +} + +.app-chat-panel__conversation-item-status--failed { + color: #b91c1c; + background: rgba(254, 202, 202, 0.6); +} + +.app-chat-panel__conversation-item-flag { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + padding: 2px 7px; + border-radius: 999px; + font-size: 10px; + line-height: 1.3; + font-weight: 800; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.app-chat-panel__conversation-item-flag--unread { + color: #1d4ed8; + background: rgba(191, 219, 254, 0.92); + box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.18); +} + +.app-chat-panel__conversation-item-delete.ant-btn { + align-self: stretch; + flex-shrink: 0; + width: 28px; + min-width: 28px; + height: auto; + margin-right: 4px; + color: #94a3b8; +} + +.app-chat-panel__conversation-main { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + min-width: 0; + min-height: 0; + position: relative; + overflow: hidden; +} + +.app-chat-panel__conversation-empty, +.app-chat-panel__conversation-empty-list { + display: flex; + flex: 1 1 auto; + min-height: 0; + height: 100%; + align-items: center; + justify-content: center; + padding: 24px; +} + +.app-chat-panel__conversation-empty .ant-empty, +.app-chat-panel__conversation-empty-list .ant-empty { + margin: auto; +} + +.app-chat-preview-card--activity { + min-width: 0; + width: 100%; + max-width: none; + margin: 0; + padding: 10px 12px 12px; + gap: 8px; + border-color: rgba(59, 130, 246, 0.18); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.94)), + radial-gradient(circle at top left, rgba(59, 130, 246, 0.08), transparent 34%); + box-shadow: + 0 8px 18px rgba(15, 23, 42, 0.06), + inset 0 1px 0 rgba(255, 255, 255, 0.72); + box-sizing: border-box; + overflow: visible; +} + +.app-chat-preview-card--activity-collapsed { + margin-bottom: 0; +} + +.app-chat-preview-card--activity-expanded { + margin-bottom: 0; +} + +.app-chat-preview-card__body--activity { + padding-top: 6px; +} + +.app-chat-preview-card__body--activity-summary { + border-top: 0; + padding-top: 0; +} + +.app-chat-preview-card--activity .app-chat-preview-card__meta, +.app-chat-preview-card--activity .app-chat-preview-card__body, +.app-chat-preview-card--activity .app-chat-panel__preview-rich, +.app-chat-preview-card--activity .previewer-ui__editor, +.app-chat-preview-card--activity .previewer-ui__editor-body { + min-width: 0; + max-width: 100%; +} + +.app-chat-preview-card--activity-collapsed .app-chat-preview-card__body--activity { + padding-top: 2px; +} + +.app-chat-preview-card--activity-expanded .app-chat-preview-card__body--activity:last-child { + padding-top: 8px; +} + +.app-chat-preview-card__glyph--activity { + color: #1d4ed8; + background: linear-gradient(180deg, rgba(191, 219, 254, 0.98), rgba(219, 234, 254, 0.94)); +} + +.app-chat-activity-card__title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.app-chat-activity-card__badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.12); + color: #1d4ed8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.app-chat-activity-card__request-id.ant-typography { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-chat-activity-card__summary { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(191, 219, 254, 0.52); +} + +.app-chat-activity-card__summary-label.ant-typography { + margin: 0; + font-size: 11px; + font-weight: 700; + color: #1d4ed8; +} + +.app-chat-message__activity-status.ant-typography { + display: block; + margin: 0; + width: 100%; + font-size: 12px; + line-height: 1.5; + color: #0f172a; + font-weight: 600; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.app-chat-preview-card--activity .app-chat-preview-card__header { + padding: 0; + align-items: flex-start; +} + +.app-chat-preview-card--activity .app-chat-preview-card__toggle.ant-btn { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + height: auto; + min-width: fit-content; + padding: 0; + font-size: 12px; + line-height: 1.2; + white-space: nowrap; +} + +.app-chat-activity-log { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + min-width: 0; + max-height: min(40vh, 360px); + padding: 10px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: thin; + -webkit-overflow-scrolling: touch; +} + +.app-chat-activity-log__item { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.app-chat-activity-log__index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 999px; + background: rgba(59, 130, 246, 0.12); + color: #1d4ed8; + font-size: 11px; + font-weight: 700; + line-height: 1; +} + +.app-chat-activity-log__line { + margin: 0; + width: 100%; + min-width: 0; + color: #334155; + padding: 6px 0 0; + font: 12px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.app-chat-panel__conversation-view, +.app-chat-panel__conversation-view-inner { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + min-width: 0; + width: 100%; + max-width: 100%; + overflow: hidden; +} + +.app-chat-panel__conversation-loading { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + background: rgba(248, 250, 252, 0.92); +} + +.app-chat-panel__resource-strip { + flex: 0 0 auto; + width: 100%; + max-width: 100%; + overflow: hidden; +} + +.app-chat-panel__resource-strip-list { + display: flex; + gap: 8px; + width: 100%; + max-width: 100%; + padding: 8px 12px 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} + +.app-chat-panel__resource-strip-list::-webkit-scrollbar { + display: none; +} + +.app-chat-panel__title-input { + width: min(240px, 48vw); +} + +.app-chat-panel__title-group { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + max-width: 100%; + position: relative; + z-index: 5; +} + +.app-chat-panel__title-copy { + display: flex; + flex: 1; + min-width: 0; +} + +.app-chat-panel__title-copy .ant-typography { + margin: 0; +} + +.app-chat-panel__title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.app-chat-panel__title-heading { + display: flex; + align-items: center; + min-width: 0; + flex: 0 1 auto; + overflow: hidden; +} + +.app-chat-panel__title-heading .ant-typography { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-panel__title-cluster { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + top: -2px; + width: 38px; + height: 38px; + border-radius: 12px; + background: linear-gradient(180deg, rgba(30, 64, 175, 0.92), rgba(37, 99, 235, 0.92)); + color: #eff6ff; + box-shadow: 0 10px 20px rgba(30, 64, 175, 0.14); + flex: 0 0 auto; + overflow: visible; + z-index: 6; +} + +.app-chat-panel__title-cluster-button, +.app-chat-panel__title-cluster-mini { + border: 0; + padding: 0; + cursor: pointer; +} + +.app-chat-panel__title-cluster-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border-radius: 12px; + background: transparent; + color: inherit; + font-size: 17px; +} + +.app-chat-panel__title-cluster-button.is-active { + background: rgba(255, 255, 255, 0.1); +} + +.app-chat-panel__title-cluster-mini { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + width: 31px; + height: 31px; + border-radius: 999px; + border: 1px solid rgba(191, 219, 254, 0.9); + background: #ffffff; + color: #334155; + font-size: 15px; + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.18); + opacity: 0; + pointer-events: none; + transform: translate(-50%, -50%) scale(0.72); + z-index: 7; + transition: + transform 180ms ease, + opacity 180ms ease, + background-color 180ms ease, + color 180ms ease; +} + +.app-chat-panel__title-cluster-mini.is-active { + background: #dbeafe; + color: #1d4ed8; +} + +.app-chat-panel__title-cluster-mini--chat { + top: 50%; + left: 50%; +} + +.app-chat-panel__title-cluster-mini--runtime { + top: 50%; + left: 50%; +} + +.app-chat-panel__title-cluster-mini--error { + top: 50%; + left: 50%; +} + +.app-chat-panel__title-cluster.is-open .app-chat-panel__title-cluster-mini { + opacity: 1; + pointer-events: auto; +} + +.app-chat-panel__title-cluster.is-open .app-chat-panel__title-cluster-mini--chat { + transform: translate(calc(-50% + 48px), calc(-50% + 0px)) scale(1); +} + +.app-chat-panel__title-cluster.is-open .app-chat-panel__title-cluster-mini--runtime { + transform: translate(calc(-50% + 38px), calc(-50% + 28px)) scale(1); +} + +.app-chat-panel__title-cluster.is-open .app-chat-panel__title-cluster-mini--error { + transform: translate(calc(-50% + 4px), calc(-50% + 48px)) scale(1); +} + +@media (max-width: 900px) { + .app-chat-panel__title-row { + gap: 6px; + } +} + +.app-chat-panel--maximized .app-chat-panel__stack, +.app-chat-panel--maximized .app-chat-panel__conversation-shell { + height: 100%; +} + +@media (max-width: 768px) { + .app-chat-panel__conversation-shell { + flex-direction: column; + } + + .app-chat-panel__conversation-list { + width: 100%; + min-width: 0; + border-right: 0; + } + + .app-chat-panel__conversation-main { + width: 100%; + } +} + +@media (min-width: 768px) { + .app-chat-panel .app-chat-panel__conversation-header .ant-typography { + font-size: 14px; + } + + .app-chat-panel .app-chat-panel__system-status .ant-typography { + font-size: 12px; + } + + .app-chat-panel .app-chat-message { + max-width: min(74%, 680px); + padding: 10px 13px; + } + + .app-chat-panel .app-chat-message__header .ant-typography { + font-size: 14px; + } + + .app-chat-panel .app-chat-message__header-meta .ant-typography { + font-size: 13px; + } + + .app-chat-panel .app-chat-message__body.ant-typography { + font-size: 17px !important; + line-height: 1.65; + } + + .app-chat-panel .app-chat-message__status { + font-size: 12px; + } + + .app-chat-panel .app-chat-message__preview-meta .ant-typography { + font-size: 13px; + } + + .app-chat-panel .app-chat-panel__composer .ant-input-textarea textarea { + font-size: 15px; + line-height: 1.5; + } + + .app-chat-panel .app-chat-panel__composer-type .ant-select-selector, + .app-chat-panel .app-chat-panel__conversation-item-title { + font-size: 13px; + } + + .app-chat-panel .app-chat-panel__conversation-item-preview { + font-size: 12px; + } +} + +.app-chat-panel__conversation-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 10px 12px 8px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(248, 250, 252, 0.86); +} + +.app-chat-panel__conversation-title { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.app-chat-panel__conversation-header .ant-typography { + margin: 0; + font-size: 13px; +} + +.app-chat-panel__conversation-toggle.ant-btn { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; +} + +.app-chat-panel__conversation-toolbar { + position: absolute; + top: 6px; + right: 8px; + z-index: 4; + display: block; + width: 24px; + height: 24px; + padding: 0; + background: transparent; + overflow: visible; + pointer-events: none; +} + +.app-chat-panel__conversation-toggle.ant-btn { + pointer-events: auto; + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + border-radius: 0; + background: transparent; + border: 0; + box-shadow: none; +} + +.app-chat-panel__conversation-toggle.ant-btn, +.app-chat-panel__conversation-toggle.ant-btn:hover, +.app-chat-panel__conversation-toggle.ant-btn:focus, +.app-chat-panel__conversation-toggle.ant-btn:active, +.app-chat-panel__conversation-toggle.ant-btn-default, +.app-chat-panel__conversation-toggle.ant-btn-default:hover, +.app-chat-panel__conversation-toggle.ant-btn-default:focus, +.app-chat-panel__conversation-toggle.ant-btn-default:active, +.app-chat-panel__conversation-toggle.ant-btn-text, +.app-chat-panel__conversation-toggle.ant-btn-text:hover, +.app-chat-panel__conversation-toggle.ant-btn-text:focus, +.app-chat-panel__conversation-toggle.ant-btn-text:active { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; +} + +.app-chat-panel__messages { + flex: 1; + min-height: 0; + min-width: 0; + width: 100%; + max-width: 100%; + padding: 10px 12px; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; +} + +.app-chat-panel__history-loader { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 0 auto 4px; + padding: 0 12px; + overflow: hidden; + color: #64748b; + font-size: 12px; + line-height: 1.4; + transition: max-height 160ms ease, opacity 160ms ease, color 160ms ease; +} + +.app-chat-panel__history-loader.is-armed { + color: #0f172a; +} + +.app-chat-panel__history-loader.is-loading { + color: #1d4ed8; +} + +.app-chat-panel__system-status-slot { + min-height: 0; + padding: 0; +} + +.app-chat-panel__system-status-slot--bottom { + padding: 0 12px 8px; +} + +.app-chat-panel__system-status { + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 26px; + margin: 0; + padding: 6px 8px; + border-left: 2px solid rgba(59, 130, 246, 0.35); + background: rgba(248, 250, 252, 0.82); + transition: opacity 140ms ease; +} + +.app-chat-panel__system-status .ant-typography { + margin: 0; + font-size: 11px; +} + +.app-chat-panel__system-status--hidden { + visibility: hidden; + opacity: 0; + pointer-events: none; +} + +.app-chat-panel__system-status-dots { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.app-chat-panel__system-status-dot { + width: 5px; + height: 5px; + border-radius: 999px; + background: #60a5fa; + opacity: 0.35; + animation: app-chat-message-loading 1.2s ease-in-out infinite; +} + +.app-chat-panel__system-status-dot:nth-child(2) { + animation-delay: 0.2s; +} + +.app-chat-panel__system-status-dot:nth-child(3) { + animation-delay: 0.4s; +} + +.app-chat-message { + --app-chat-message-fade-end: rgba(248, 250, 252, 0.96); + gap: 4px; + width: fit-content; + max-width: min(72%, 560px); + min-width: 0; + padding: 8px 11px; + border-radius: 0; + box-shadow: 0 6px 14px rgba(15, 23, 42, 0.05); + align-self: flex-start; + margin-left: 0; + margin-right: 56px; +} + +.app-chat-message-stack { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + align-items: flex-start; +} + +.app-chat-message-stack--user { + align-items: flex-end; +} + +.app-chat-message-stack__previews { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.app-chat-message--codex { + --app-chat-message-fade-end: rgba(248, 251, 255, 0.96); + margin-left: 8px; + margin-right: 64px; + background: linear-gradient(180deg, #edf4ff, #f8fbff); +} + +.app-chat-message--system-inline { + --app-chat-message-fade-end: rgba(241, 245, 249, 0.96); + margin-left: 8px; + margin-right: 64px; + border-left: 2px solid rgba(59, 130, 246, 0.32); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.92)); +} + +.app-chat-message__header-meta { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 4px; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; +} + +.app-chat-message__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; +} + +.app-chat-message__header .ant-typography { + margin: 0; + font-size: 12px; + line-height: 1.2; +} + +.app-chat-message__header-meta > .ant-typography:first-child { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-message__header-action { + width: 24px; + min-width: 24px; + height: 24px; + padding: 0; + margin-left: auto; + flex: none; +} + +.app-chat-message__header-meta .ant-typography { + font-size: 11px; + white-space: nowrap; +} + +.app-chat-message__status { + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: 4px; + font-size: 10px; + white-space: nowrap; +} + +.app-chat-message__status--retrying { + color: #0369a1; +} + +.app-chat-message__status--failed { + color: #b91c1c; +} + +.app-chat-message__retry.ant-btn, +.app-chat-message__cancel.ant-btn { + height: 22px; + padding-inline: 4px; + margin-left: 2px; +} + +.app-chat-message__retry.ant-btn { + color: #b91c1c; +} + +.app-chat-message__retry.ant-btn:hover, +.app-chat-message__retry.ant-btn:focus { + color: #991b1b; +} + +.app-chat-message__delete.ant-btn { + min-width: 22px; + padding-inline: 2px; +} + +.app-chat-message--user { + --app-chat-message-fade-end: rgba(238, 252, 244, 0.96); + align-self: flex-end; + margin-left: 64px; + margin-right: 8px; + background: linear-gradient(180deg, #dff7ea, #eefcf4); +} + +.app-chat-message--user .app-chat-message__body, +.app-chat-message--user .app-chat-message__expand { + text-align: right; +} + +.app-chat-message__body { + margin: 0; + width: 100%; + max-width: 100%; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.app-chat-message__body--collapsed { + position: relative; + max-height: calc(1.45em * 6); + overflow: hidden; + padding-bottom: 16px; +} + +.app-chat-message__body--collapsed::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 28px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0), var(--app-chat-message-fade-end) 78%); + pointer-events: none; +} + +.app-chat-message__body--system-status { + color: #475569; +} + +.app-chat-message__request-detail { + width: 100%; + margin-top: 2px; + color: #7f1d1d; + font-size: 11px; + line-height: 1.45; + text-align: right; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.app-chat-preview-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: none; + padding: 8px 10px 10px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); + overflow: hidden; + height: auto; +} + +.app-chat-preview-card--collapsed { + gap: 0; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.app-chat-message-stack--codex .app-chat-preview-card, +.app-chat-message-stack--system .app-chat-preview-card { + margin-left: 8px; + margin-right: 24px; +} + +.app-chat-message-stack--user .app-chat-preview-card { + margin-left: 24px; + margin-right: 8px; +} + +.app-chat-preview-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; +} + +.app-chat-preview-card--collapsed .app-chat-preview-card__header { + border: 1px solid rgba(148, 163, 184, 0.22); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(241, 245, 249, 0.92)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.app-chat-preview-card__meta { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.app-chat-preview-card__glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + min-width: 20px; + height: 20px; + margin-top: 1px; + color: #475569; + background: rgba(226, 232, 240, 0.9); +} + +.app-chat-preview-card__titles { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.app-chat-preview-card__label.ant-typography, +.app-chat-preview-card__kind.ant-typography { + margin: 0; + max-width: 100%; +} + +.app-chat-preview-card__label.ant-typography { + font-size: 12px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-chat-preview-card__kind.ant-typography { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-chat-preview-card__toggle.ant-btn { + height: 20px; + min-width: 20px; + padding: 0; + flex: 0 0 auto; +} + +@media (max-width: 1180px) { + .app-chat-panel { + height: 100%; + max-height: none; + border-radius: 0; + } + + .app-chat-panel--tablet-app { + align-self: stretch; + width: 100%; + max-width: 100%; + margin-inline: 0; + border-radius: 0; + overflow: hidden; + } + + .app-chat-panel.ant-card .ant-card-head { + padding: 0 14px; + } + + .app-chat-panel.ant-card .ant-card-body { + padding: 10px; + } + + .app-chat-panel__conversation-shell, + .app-chat-panel__conversation-main, + .app-chat-panel__conversation-view, + .app-chat-panel__conversation-view-inner, + .app-chat-panel__messages, + .app-chat-panel__composer, + .app-chat-panel__composer-input-shell { + min-width: 0; + width: 100%; + max-width: 100%; + } + + .app-chat-message { + width: auto; + max-width: calc(100% - 40px); + margin-right: 20px; + } + + .app-chat-message--codex, + .app-chat-message--system-inline { + margin-left: 4px; + margin-right: 20px; + } + + .app-chat-message--user { + margin-left: 20px; + margin-right: 4px; + } + + .app-chat-message-stack--codex .app-chat-preview-card, + .app-chat-message-stack--system .app-chat-preview-card, + .app-chat-message-stack--user .app-chat-preview-card { + margin-left: 4px; + margin-right: 4px; + max-width: calc(100% - 8px); + } + + .app-chat-panel__composer-queue { + width: min(220px, calc(100% - 88px)); + } +} + +.app-chat-preview-card__body { + display: flex; + min-height: 0; + border-top: 1px solid rgba(148, 163, 184, 0.18); + padding-top: 8px; + width: 100%; +} + +.app-chat-panel__preview-rich { + width: 100%; + min-width: 0; +} + +.app-chat-panel__preview-rich .previewer-ui__editor, +.app-chat-panel__preview-rich .codex-diff-previewer__diff-list, +.app-chat-panel__preview-rich .codex-diff-previewer__diff-section { + width: 100%; +} + +.app-chat-panel__preview-rich .previewer-ui__editor { + border-color: rgba(15, 23, 42, 0.58); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.app-chat-panel__preview-rich .previewer-ui__editor-tab { + font-weight: 700; + letter-spacing: 0.1em; +} + +.app-chat-panel__preview-rich .previewer-ui__editor-body { + max-height: 420px; + overflow: auto; +} + +.app-chat-panel__preview-rich .codex-diff-previewer__diff-toolbar { + display: none; +} + +.app-chat-panel__preview-rich .codex-diff-previewer__diff-section { + border-radius: 0; +} + +.app-chat-panel__preview-rich .codex-diff-previewer__file-name { + color: #e2e8f0; +} + +.app-chat-panel__preview-rich--markdown { + padding: 4px 2px 0; +} + +.app-chat-message__preview-image, +.app-chat-message__preview-video, +.app-chat-message__preview-frame { + width: 100%; + max-height: 200px; + min-height: 120px; + border: 1px solid rgba(148, 163, 184, 0.18); + object-fit: contain; + background: #f8fafc; +} + +.app-chat-message__preview-text { + width: 100%; + max-height: 180px; + margin: 0; + padding: 8px; + overflow: auto; + background: #f8fafc; + border: 1px solid rgba(148, 163, 184, 0.18); + font: 11px/1.5 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.app-chat-message__expand.ant-btn { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 4px; + width: auto; + min-width: 0; + height: 26px; + padding-inline: 8px; + font-size: 12px; + color: #475569; + border-radius: 999px; +} + +.app-chat-message__expand.ant-btn:hover, +.app-chat-message__expand.ant-btn:focus { + color: #0f172a; + background: rgba(148, 163, 184, 0.12); +} + +.app-chat-panel__scroll-jump { + position: absolute; + left: 50%; + bottom: clamp(132px, 18vh, 176px); + transform: translateX(-50%); + z-index: 3; +} + +.app-chat-panel__scroll-jump .ant-btn { + width: 28px; + min-width: 28px; + height: 28px; + padding: 0; + border-radius: 999px; + box-shadow: 0 6px 12px rgba(15, 23, 42, 0.14); +} + +.app-chat-panel__composer { + gap: 8px; + padding: 10px 12px 12px; + border-top: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 0; + background: rgba(248, 250, 252, 0.94); + box-shadow: none; +} + +.app-chat-panel__composer-input-shell { + position: relative; + width: 100%; + min-width: 0; +} + +.app-chat-panel__composer-queue { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + width: min(320px, calc(100% - 112px)); + pointer-events: auto; +} + +.app-chat-panel__composer-queue-count { + padding: 2px 8px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08); +} + +.app-chat-panel__composer-queue-list { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; + width: 100%; + max-height: 188px; + overflow-y: auto; +} + +.app-chat-panel__composer-queue-chip, +.app-chat-panel__composer-queue-more { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 6px 10px; + border: 1px solid rgba(96, 165, 250, 0.2); + border-radius: 12px; + background: rgba(239, 246, 255, 0.96); + box-shadow: 0 8px 20px rgba(37, 99, 235, 0.1); + color: #1e3a8a; +} + +.app-chat-panel__composer-queue-chip-main { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} + +.app-chat-panel__composer-queue-chip-actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex: none; +} + +.app-chat-panel__composer-queue-chip-actions .ant-btn { + color: #1d4ed8; +} + +.app-chat-panel__composer-queue-order { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.12); + font-size: 11px; + font-weight: 700; +} + +.app-chat-panel__composer-queue-text, +.app-chat-panel__composer-queue-more { + font-size: 12px; + line-height: 1.3; +} + +.app-chat-panel__composer-queue-text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-chat-panel__composer .ant-input-textarea { + width: 100%; + min-width: 0; +} + +.app-chat-panel__composer .ant-input-textarea textarea { + width: 100%; + font-size: 13px; + line-height: 1.4; + min-height: 88px; + padding: 8px 14px 12px; +} + +.app-chat-panel__composer-input-shell--with-queue .ant-input-textarea textarea { + padding-top: 76px; +} + +.app-chat-panel__composer-topline, +.app-chat-panel__composer-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.app-chat-panel__composer-topline { + width: 100%; + justify-content: stretch; + flex-wrap: nowrap; +} + +.app-chat-panel__composer-utility-buttons { + flex: none; +} + +.app-chat-panel__composer-type { + flex: 1; + min-width: 0; +} + +.app-chat-panel__composer-action-buttons { + display: inline-flex; + gap: 6px; +} + +.app-chat-panel__composer-utility-buttons { + display: inline-flex; + gap: 6px; +} + +.app-chat-panel__composer-file-input { + display: none; +} + +.app-chat-panel__composer-attachment-strip { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} + +.app-chat-panel__composer-attachment-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 100%; + padding: 4px 4px 4px 8px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 999px; + background: rgba(255, 255, 255, 0.96); + color: #334155; +} + +.app-chat-panel__composer-attachment-name { + max-width: min(240px, 52vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + line-height: 1.3; +} + +.app-chat-panel__composer-attachment-remove.ant-btn { + width: 22px; + min-width: 22px; + height: 22px; + padding: 0; + border-radius: 999px; + color: #64748b; +} + +.app-chat-panel__composer .ant-btn { + height: 28px; + padding-inline: 8px; + border-radius: 8px; + font-size: 12px; +} + +.app-chat-panel__composer-action-buttons .ant-btn, +.app-chat-panel__composer-type .ant-select-selector { + min-height: 28px; +} + +.app-chat-panel__composer-action-buttons .ant-btn-icon-only { + width: 28px; + min-width: 28px; + padding-inline: 0; +} + +.app-chat-panel__composer-type .ant-select-selector { + padding-block: 2px; +} + +.app-chat-panel__composer-type-note, +.app-chat-panel__composer-actions .ant-typography { + font-size: 12px; +} + +.app-chat-panel__composer-hint, +.app-chat-panel__composer-type-note { + display: block; +} + +.app-chat-panel__type-option { + display: flex; + flex-direction: column; + gap: 2px; +} + +.app-chat-panel__resource-strip { + position: absolute; + top: 38px; + right: 8px; + left: auto; + z-index: 4; + display: flex; + flex-direction: column; + gap: 6px; + width: min(240px, calc(100% - 16px)); + max-height: min(28vh, 180px); + padding: 8px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 12px; + background: rgba(246, 248, 252, 0.96); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); + overflow: hidden; +} + +.app-chat-panel__resource-strip-list { + display: flex; + flex-direction: column; + gap: 6px; + overflow: auto; +} + +.app-chat-panel__resource-strip-empty.ant-typography { + margin: 0; + font-size: 11px; + line-height: 1.5; +} + +.app-chat-panel__resource-chip { + display: inline-flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + width: 100%; + padding: 6px 8px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 10px; + background: rgba(255, 255, 255, 0.9); + color: #0f172a; + cursor: pointer; + font-size: 11px; + text-align: left; +} + +.app-chat-panel__preview-stage { + display: flex; + min-height: 0; + padding: 0 18px; +} + +.app-chat-panel__preview-stage--modal { + padding: 0; +} + +.app-chat-panel__preview-stage > * { + width: 100%; +} + +.app-chat-panel__preview-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 220px; +} + +.app-chat-panel__conversation-view { + position: relative; + display: flex; + flex: 1; + min-height: 0; +} + +.app-chat-panel__conversation-view-inner { + display: flex; + flex: 1; + min-height: 0; + min-width: 0; + flex-direction: column; + overflow: hidden; +} + +.app-chat-panel__conversation-view-inner.is-loading { + pointer-events: none; +} + +.app-chat-panel__conversation-loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex: 1; + min-height: 360px; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + padding: 32px 24px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 24px; + background: + radial-gradient(circle at top, rgba(191, 219, 254, 0.45), transparent 52%), + linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(248, 250, 252, 0.86)); + backdrop-filter: blur(6px); + text-align: center; +} + +.app-chat-panel__preview-image, +.app-chat-panel__preview-video, +.app-chat-panel__preview-frame { + width: 100%; + height: 100%; + min-height: 320px; + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + background: #0f172a; + object-fit: contain; +} + +.app-chat-panel__preview-frame { + background: #fff; +} + +.app-chat-panel__preview-text { + min-height: 320px; + margin: 0; + padding: 16px; + overflow: auto; + border-radius: 16px; + background: #0f172a; + color: #dbeafe; + font: 13px/1.6 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; +} + +.app-chat-panel__preview-file { + display: flex; + flex-direction: column; + justify-content: center; + gap: 12px; + min-height: 220px; + padding: 18px; + border-radius: 16px; + background: rgba(248, 250, 252, 0.88); + border: 1px dashed rgba(148, 163, 184, 0.35); +} + +.app-chat-panel__preview-modal .ant-modal-body { + padding-top: 12px; +} + +.app-chat-panel__preview-modal { + z-index: 1600; +} + +.app-chat-panel__delete-confirm-modal { + z-index: 1700 !important; +} + +.app-chat-panel__delete-confirm-modal .ant-modal-content { + border-radius: 18px; + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.28); +} + +.app-chat-panel__preview-modal-body { + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-chat-panel__preview-modal-meta { + display: flex; + justify-content: flex-start; +} + +.app-chat-panel__connection-dot--connecting { + background: #f59e0b; +} + +@keyframes app-chat-message-loading { + 0%, + 80%, + 100% { + transform: translateY(0); + opacity: 0.35; + } + + 40% { + transform: translateY(-2px); + opacity: 1; + } +} + +@media (max-width: 720px) { + .app-chat-panel { + height: 100%; + min-height: 100%; + border-radius: 0; + overflow: hidden; + } + + .app-chat-panel .ant-card-head { + flex: 0 0 auto; + padding-inline: 8px; + } + + .app-chat-panel .ant-card-head-wrapper { + min-width: 0; + gap: 8px; + } + + .app-chat-panel .ant-card-head-title, + .app-chat-panel .ant-card-extra { + padding-block: 8px; + } + + .app-chat-panel .ant-card-head-title { + flex: 1 1 auto; + min-width: 0; + } + + .app-chat-panel .ant-card-extra { + flex: 0 0 auto; + min-width: fit-content; + } + + .app-chat-panel .ant-card-body { + padding: 0; + } + + .app-chat-panel__stack, + .app-chat-panel__conversation-shell, + .app-chat-panel__conversation-list, + .app-chat-panel__conversation-main { + height: 100%; + overflow: hidden; + } + + .app-chat-panel__conversation-shell { + border-radius: 0; + border-left: 0; + border-right: 0; + box-shadow: none; + overscroll-behavior-y: none; + } + + .app-chat-panel__conversation-list-header { + padding-inline: 10px; + } + + .app-chat-panel__conversation-list-search { + padding-inline: 8px; + } + + .app-chat-panel__conversation-list-body { + padding-inline: 8px; + padding-bottom: 8px; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + + .app-chat-panel__messages, + .app-chat-panel__composer, + .app-chat-panel__resource-strip { + overscroll-behavior-y: none; + } + + .app-chat-panel__conversation-header, + .app-chat-panel__composer-actions { + flex-direction: column; + align-items: stretch; + } + + .app-chat-panel__composer-topline { + flex-direction: row; + align-items: center; + } + + .app-chat-panel__conversation-badges { + align-items: flex-start; + } + + .app-chat-message { + max-width: 100%; + } + + .app-chat-panel__messages, + .app-chat-panel__composer, + .app-chat-panel__preview-stage, + .app-chat-panel__resource-strip { + padding-left: 12px; + padding-right: 12px; + } + + .app-chat-panel__resource-strip-list { + overflow: auto; + flex-wrap: nowrap; + padding-bottom: 2px; + } + + .app-chat-panel__resource-chip { + min-width: 160px; + } + + .app-chat-panel__preview-image, + .app-chat-panel__preview-video, + .app-chat-panel__preview-frame, + .app-chat-panel__preview-text { + min-height: 220px; + } +} + +.app-chat-runtime { + display: flex; + flex: 1; + flex-direction: column; + gap: 14px; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.app-chat-runtime__summary-strip { + display: block; +} + +.app-chat-runtime__summary-card { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 14px 16px; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.05); +} + +.app-chat-runtime__summary-metric { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.app-chat-runtime__summary-metric .ant-typography { + margin: 0; +} + +.app-chat-runtime__summary-status.ant-typography { + margin-inline-start: auto; +} + +.app-chat-runtime__session-strip { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + min-width: 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; +} + +.app-chat-runtime__session-chip { + display: inline-flex; + flex-direction: column; + gap: 4px; + min-width: 180px; + padding: 10px 12px; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(248, 250, 252, 0.88); + text-align: left; + cursor: pointer; + touch-action: manipulation; +} + +.app-chat-runtime__session-chip--active { + border-color: rgba(37, 99, 235, 0.35); + background: rgba(239, 246, 255, 0.96); +} + +.app-chat-runtime__content { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + flex: 1; + min-height: 0; + min-width: 0; +} + +.app-chat-runtime__section { + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.82); + overflow: hidden; +} + +.app-chat-runtime__section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.app-chat-runtime__list { + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; + min-height: 0; + min-width: 0; + padding: 12px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.app-chat-runtime__empty { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 220px; +} + +.app-chat-runtime__job { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; + padding: 14px; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.96)); +} + +.app-chat-runtime__job--active { + border-color: rgba(37, 99, 235, 0.32); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.1); +} + +.app-chat-runtime__job-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.app-chat-runtime__job-actions { + justify-content: flex-end; +} + +.app-chat-runtime__job-actions .ant-btn { + touch-action: manipulation; +} + +.app-chat-runtime__job-headline { + display: flex; + flex-direction: column; + gap: 4px; +} + +.app-chat-runtime__job-summary.ant-typography { + margin: 0; + color: #0f172a; + white-space: pre-wrap; + word-break: break-word; +} + +.app-chat-runtime__job-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 10px; +} + +.app-chat-runtime__log-modal { + display: flex; + flex: 1; + flex-direction: column; + gap: 12px; + min-height: 0; + height: calc(100dvh - 56px); + padding: 20px 24px 24px; + background: + radial-gradient(circle at top right, rgba(59, 130, 246, 0.1), transparent 28%), + linear-gradient(180deg, rgba(248, 250, 252, 0.98), rgba(241, 245, 249, 0.98)); +} + +.app-chat-runtime__drawer .ant-drawer-content { + background: transparent; +} + +.app-chat-runtime__drawer .ant-drawer-header { + padding: 18px 24px; + border-bottom: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(18px); +} + +.app-chat-runtime__drawer .ant-drawer-body { + display: flex; + min-height: 0; +} + +.app-chat-runtime__log-state { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + min-height: 0; + padding: 24px; +} + +.app-chat-runtime__log-viewer { + margin: 0; + flex: 1; + min-height: 0; + overflow: auto; + padding: 18px; + border-radius: 20px; + background: #0f172a; + color: #e2e8f0; + font-size: 12px; + line-height: 1.55; + font-family: + 'JetBrains Mono', 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; +} + +@media (max-width: 960px) { + .app-chat-runtime__content, + .app-chat-runtime__job-meta { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 768px) { + .app-chat-runtime { + overflow: hidden; + } + + .app-chat-runtime__summary-card { + padding: 14px; + border-radius: 16px; + align-items: flex-start; + gap: 10px 14px; + } + + .app-chat-runtime__summary-metric { + flex: 1 1 calc(50% - 8px); + } + + .app-chat-runtime__summary-status.ant-typography { + width: 100%; + margin-inline-start: 0; + } + + .app-chat-runtime__session-strip { + margin-inline: -2px; + padding-inline: 2px; + padding-bottom: 6px; + } + + .app-chat-runtime__session-chip { + min-width: min(240px, 82vw); + padding: 12px 14px; + border-radius: 16px; + } + + .app-chat-runtime__content { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + padding-right: 2px; + -webkit-overflow-scrolling: touch; + } + + .app-chat-runtime__section { + flex: 0 0 auto; + min-height: 320px; + border-radius: 18px; + } + + .app-chat-runtime__section--recent { + min-height: 260px; + } + + .app-chat-runtime__section-header { + padding: 14px; + } + + .app-chat-runtime__list { + padding: 10px; + overflow-y: visible; + } + + .app-chat-runtime__job { + padding: 14px; + border-radius: 16px; + } + + .app-chat-runtime__job-top { + flex-direction: column; + align-items: stretch; + } + + .app-chat-runtime__job-top > .ant-btn { + width: 100%; + min-height: 40px; + border-radius: 12px; + } + + .app-chat-runtime__job-actions { + width: 100%; + justify-content: stretch; + } + + .app-chat-runtime__job-actions .ant-space-item { + flex: 1 1 calc(50% - 4px); + min-width: 0; + } + + .app-chat-runtime__job-actions .ant-btn { + width: 100%; + min-height: 40px; + padding-inline: 12px; + border-radius: 12px; + } + + .app-chat-runtime__job-meta { + gap: 8px; + } + + .app-chat-runtime__log-modal { + height: calc(100dvh - 52px); + padding: 16px; + } + + .app-chat-runtime__drawer .ant-drawer-header { + padding: 14px 16px; + } + + .app-chat-runtime__log-viewer { + padding: 12px; + font-size: 11px; + } +} + +.chat-v2 { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.chat-v2__toolbar { + display: flex; + justify-content: center; + padding: 8px 0 12px; +} + +.chat-v2__chat-layout { + display: flex; + min-height: 0; + height: 100%; +} + +.chat-v2__conversation-list .ant-list-item { + padding: 0; + border-block-end: 0; +} + +.chat-v2__conversation-item { + appearance: none; + width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px 14px; + border: 0; + outline: none; + box-shadow: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.chat-v2__conversation-item:hover { + background: rgba(15, 23, 42, 0.04); +} + +.chat-v2__conversation-item:focus, +.chat-v2__conversation-item:focus-visible { + outline: none; + box-shadow: none; +} + +.chat-v2__conversation-item--active { + background: rgba(22, 119, 255, 0.08); +} + +.chat-v2__conversation-title { + color: #111827; + font-weight: 600; +} + +.chat-v2__conversation-preview { + color: #6b7280; + font-size: 13px; +} + +@media (min-width: 1181px) { + .app-chat-panel { + background: + linear-gradient(180deg, rgba(244, 246, 248, 0.98), rgba(238, 241, 244, 0.98)), + radial-gradient(circle at top left, rgba(148, 163, 184, 0.08), transparent 28%); + } + + .app-chat-panel .ant-card-head { + background: rgba(248, 249, 250, 0.9); + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + } + + .app-chat-panel__conversation-shell { + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 14px 28px rgba(15, 23, 42, 0.06); + } + + .app-chat-panel__conversation-list { + background: rgba(246, 247, 249, 0.92); + border-right: 1px solid rgba(148, 163, 184, 0.12); + } + + .app-chat-panel__conversation-section-title, + .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-title { + color: #475569; + } + + .app-chat-panel__conversation-section-count, + .app-chat-panel__conversation-section-header--unread .app-chat-panel__conversation-section-count { + background: rgba(226, 232, 240, 0.92); + color: #475569; + box-shadow: none; + } + + .app-chat-panel__conversation-item { + border-color: rgba(148, 163, 184, 0.14); + background: rgba(255, 255, 255, 0.92); + } + + .app-chat-panel__conversation-item--active { + border-color: rgba(100, 116, 139, 0.26); + background: rgba(248, 250, 252, 0.98); + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); + } + + .app-chat-panel__conversation-item--unread { + border-color: rgba(148, 163, 184, 0.28); + background: + linear-gradient(90deg, rgba(241, 245, 249, 0.98), rgba(248, 250, 252, 0.99) 42%, rgba(255, 255, 255, 0.99) 78%), + #fff; + box-shadow: + inset 4px 0 0 rgba(100, 116, 139, 0.62), + 0 10px 24px rgba(15, 23, 42, 0.06); + } + + .app-chat-panel__conversation-item--unread-section { + border-color: rgba(148, 163, 184, 0.32); + background: + linear-gradient(135deg, rgba(241, 245, 249, 1), rgba(248, 250, 252, 0.99) 46%, rgba(255, 255, 255, 1) 86%), + #fff; + box-shadow: + inset 6px 0 0 rgba(100, 116, 139, 0.68), + 0 12px 26px rgba(15, 23, 42, 0.07); + } + + .app-chat-panel__conversation-item--unread::after, + .app-chat-panel__conversation-item-unread-dot { + background: #64748b; + box-shadow: + 0 0 0 4px rgba(226, 232, 240, 0.9), + 0 0 8px rgba(100, 116, 139, 0.18); + } + + .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread { + border-color: rgba(100, 116, 139, 0.34); + background: + linear-gradient(90deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 0.99) 34%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), + #fff; + box-shadow: + inset 4px 0 0 rgba(100, 116, 139, 0.82), + 0 12px 24px rgba(15, 23, 42, 0.08); + } + + .app-chat-panel__conversation-item--active.app-chat-panel__conversation-item--unread-section { + border-color: rgba(100, 116, 139, 0.38); + background: + linear-gradient(135deg, rgba(226, 232, 240, 1), rgba(241, 245, 249, 1) 36%, rgba(248, 250, 252, 1) 68%, rgba(255, 255, 255, 1) 88%), + #fff; + box-shadow: + inset 6px 0 0 rgba(100, 116, 139, 0.84), + 0 14px 28px rgba(15, 23, 42, 0.08); + } + + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title, + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time, + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-id, + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-preview { + color: #334155; + } + + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-title-wrap { + background: linear-gradient(90deg, rgba(241, 245, 249, 0.92), rgba(241, 245, 249, 0)); + } + + .app-chat-panel__conversation-item--unread .app-chat-panel__conversation-item-time { + background: rgba(241, 245, 249, 0.96); + } + + .app-chat-panel__conversation-item-flag--unread, + .app-chat-panel__conversation-item-unread-badge { + color: #475569; + background: rgba(226, 232, 240, 0.92); + border-color: rgba(148, 163, 184, 0.24); + box-shadow: none; + } +} diff --git a/src/app/main/MainChatPanel.tsx b/src/app/main/MainChatPanel.tsx new file mode 100644 index 0000000..3797383 --- /dev/null +++ b/src/app/main/MainChatPanel.tsx @@ -0,0 +1,2502 @@ +import { + AppstoreOutlined, + BellFilled, + BellOutlined, + CloseOutlined, + DownloadOutlined, + EditOutlined, + ExclamationCircleOutlined, + FullscreenExitOutlined, + FullscreenOutlined, + LinkOutlined, + MessageOutlined, + PaperClipOutlined, + PlusOutlined, + ReloadOutlined, + SearchOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import { Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography, message } from 'antd'; +import type { TextAreaRef } from 'antd/es/input/TextArea'; +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAppStore } from '../../store'; +import { useAppConfig } from './appConfig'; +import { chatConnectionGateway, chatGateway } from './chatV2'; +import { emitChatConversationsUpdated } from './chatV2/data/chatClientEvents'; +import { useConversationComposerController } from './chatV2/hooks/useConversationComposerController'; +import { useConversationRoomActionsController } from './chatV2/hooks/useConversationRoomActionsController'; +import { useConversationListController } from './chatV2/hooks/useConversationListController'; +import { useConversationRoomController } from './chatV2/hooks/useConversationRoomController'; +import { useRuntimeController } from './chatV2/hooks/useRuntimeController'; +import { useConversationViewController } from './chatV2/hooks/useConversationViewController'; +import { useConversationViewportController } from './chatV2/hooks/useConversationViewportController'; +import { ChatPreviewBody } from './mainChatPanel/ChatPreviewBody'; +import { normalizeChatResourceUrl } from './mainChatPanel/chatResourceUrl'; +import { setSharedActiveConversationSnapshot } from './mainChatPanel/sharedActiveConversation'; +import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from './chatTypeAccess'; +import { useTokenAccess } from './tokenAccess'; +import { + ChatConversationView, + ChatRuntimeDashboard, + copyText, + createActivityLogPlaceholder, + createChatMessage, + createLocalMessage, + ErrorLogViewer, + getStoredChatSessionLastTypeId, + isPreparingChatReplyText, + setStoredChatSessionLastTypeId, + upsertChatMessage, + useErrorLogs, +} from './mainChatPanel'; +import type { + ChatComposerAttachment, + ChatConversationDetailResponse, + ChatConversationRequest, + ChatConversationSummary, + ChatJobEvent, + ChatMessage, + ChatPanelView, + ChatRuntimeSnapshot, + ChatViewContext, + MainChatPanelProps, +} from './mainChatPanel/types'; +import './MainChatPanel.css'; +import './MainChatPanel.hotfix.css'; + +const { Text } = Typography; + +type ChatTypeOption = { + value: string; + label: string; + description: string; + isTemplate: boolean; + disabled?: boolean; +}; + +type PreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; + +type PreviewItem = { + id: string; + label: string; + url: string; + kind: PreviewKind; + source: 'message' | 'context'; +}; + +type PendingChatRequest = { + sessionId: string; + requestId: string; + text: string; + mode: 'queue' | 'direct'; + chatTypeId: string; + chatTypeLabel: string; + chatTypeDescription: string; + chatTypeIsTemplate: boolean; + retryCount: number; + failed: boolean; +}; + +type PendingContextConfirm = { + mode: 'queue' | 'direct'; + text: string; + chatTypeId: string; + chatTypeLabel: string; + chatTypeDescription: string; + chatTypeIsTemplate: boolean; + includedContextCount: number; + omittedContextCount: number; +}; + +const CHAT_MAX_RETRY_ATTEMPTS = 5; +const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function getCachedSessionMessages(cache: Map, sessionId: string) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return [] as ChatMessage[]; + } + + const cached = cache.get(normalizedSessionId); + + if (cached) { + return cached; + } + + return [] as ChatMessage[]; +} + +function createConversationSessionId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +function isActiveChatSessionInForeground(args: { + sessionId: string; + activeSessionId: string; + activeView: ChatPanelView; + isConversationPaneClosed: boolean; + isMobileViewport: boolean; + isMobileConversationView: boolean; +}) { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + return false; + } + + if (args.sessionId !== args.activeSessionId) { + return false; + } + + if (args.activeView !== 'chat' || args.isConversationPaneClosed) { + return false; + } + + if (args.isMobileViewport && !args.isMobileConversationView) { + return false; + } + + return true; +} + +function createConversationPreviewText(text: string) { + const normalizedText = String(text ?? '').trim(); + const normalized = + normalizedText.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`) + ? normalizedText + .slice(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`.length) + .split('\n\n') + .map((line) => line.trim()) + .filter(Boolean) + .at(-1) ?? '' + : normalizedText.replace(/\s+/g, ' ').trim(); + return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; +} + +function resolveConversationListPreviewText(preview: string) { + const normalized = createConversationPreviewText(preview); + + if (!normalized) { + return '๋Œ€ํ™”๊ฐ€ ์•„์ง ์—†์Šต๋‹ˆ๋‹ค.'; + } + + return normalized; +} + +function compareConversationItemsByLatestChat(left: ChatConversationSummary, right: ChatConversationSummary) { + const timeDiff = getConversationLatestActivityTime(right) - getConversationLatestActivityTime(left); + + if (timeDiff !== 0) { + return timeDiff; + } + + return left.sessionId.localeCompare(right.sessionId); +} + +function getConversationLatestActivityTime(item: ChatConversationSummary) { + const latestTimestamp = item.lastMessageAt || item.updatedAt || item.createdAt; + const parsedTime = latestTimestamp ? new Date(latestTimestamp).getTime() : 0; + + return Number.isFinite(parsedTime) ? parsedTime : 0; +} + +function getLatestConversationPreviewMessage(messages: ChatMessage[]) { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const candidate = messages[index]; + + if (candidate.author !== 'user' && candidate.author !== 'codex') { + continue; + } + + const preview = createConversationPreviewText(candidate.text); + + if (preview) { + return candidate; + } + } + + return null; +} + +function formatConversationListTimestamp(timestamp: string | null | undefined) { + if (!timestamp) { + return ''; + } + + const date = new Date(timestamp); + + if (Number.isNaN(date.getTime())) { + return ''; + } + + const now = new Date(); + const isSameYear = now.getFullYear() === date.getFullYear(); + const isSameDay = + isSameYear && now.getMonth() === date.getMonth() && now.getDate() === date.getDate(); + const timeLabel = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + + if (isSameDay) { + return timeLabel; + } + + const monthDayLabel = `${date.getMonth() + 1}/${date.getDate()}`; + return isSameYear ? `${monthDayLabel} ${timeLabel}` : `${date.getFullYear()}.${monthDayLabel} ${timeLabel}`; +} + +function isActivityLogMessage(message: ChatMessage) { + return message.author === 'system' && message.text.startsWith(`${CHAT_ACTIVITY_MESSAGE_PREFIX}\n`); +} + +function normalizeContextMessageText(message: ChatMessage) { + return message.text.replace(/\s+/g, ' ').trim(); +} + +function summarizeRecentContext(messages: ChatMessage[], maxMessages: number, maxChars: number) { + const candidates = messages.filter((message) => { + if (message.author !== 'user' && message.author !== 'codex') { + return false; + } + + return !isActivityLogMessage(message) && Boolean(normalizeContextMessageText(message)); + }); + + const selected: ChatMessage[] = []; + let totalChars = 0; + + for (let index = candidates.length - 1; index >= 0; index -= 1) { + const message = candidates[index]; + const nextText = normalizeContextMessageText(message); + const nextChars = nextText.length; + + if ( + selected.length >= maxMessages || + (selected.length > 0 && totalChars + nextChars > maxChars) + ) { + break; + } + + selected.unshift(message); + totalChars += nextChars; + } + + return { + includedCount: selected.length, + omittedCount: Math.max(0, candidates.length - selected.length), + }; +} + +function buildConversationTitleFromMessages(messages: ChatMessage[], fallbackTitle: string) { + if (fallbackTitle && fallbackTitle !== '์ƒˆ ๋Œ€ํ™”') { + return fallbackTitle; + } + + const firstUserMessage = messages.find((message) => message.author === 'user' && message.text.trim()); + return firstUserMessage ? createConversationPreviewText(firstUserMessage.text) || '์ƒˆ ๋Œ€ํ™”' : fallbackTitle || '์ƒˆ ๋Œ€ํ™”'; +} + +function buildConversationTitleFromRequestText(requestText: string, fallbackTitle: string) { + if (fallbackTitle && fallbackTitle !== '์ƒˆ ๋Œ€ํ™”') { + return fallbackTitle; + } + + return createConversationPreviewText(requestText) || fallbackTitle || '์ƒˆ ๋Œ€ํ™”'; +} + +function buildMessageSyncKey(messages: ChatMessage[]) { + if (messages.length === 0) { + return '0'; + } + + const latestMessage = messages[messages.length - 1]; + return `${messages.length}:${latestMessage.id}:${latestMessage.text.length}:${latestMessage.timestamp}`; +} + +function buildAttachmentMessageBlock(attachments: ChatComposerAttachment[]) { + if (attachments.length === 0) { + return ''; + } + + return ['์ฒจ๋ถ€ ํŒŒ์ผ:', ...attachments.map((attachment) => `- ${attachment.name}: ${attachment.path}`)].join('\n'); +} + +function getSessionIdFromSearch(search: string) { + const sessionId = new URLSearchParams(search).get('sessionId')?.trim() ?? ''; + return sessionId || null; +} + +function getRuntimeLogRequestIdFromSearch(search: string) { + const requestId = new URLSearchParams(search).get('runtimeRequestId')?.trim() ?? ''; + return requestId || null; +} + +function getRequestedChatViewFromSearch(search: string): ChatPanelView | null { + const view = new URLSearchParams(search).get('chatView')?.trim() ?? ''; + return view === 'chat' || view === 'runtime' || view === 'errors' ? view : null; +} + +function isRequestDeletionAllowed(status?: ChatConversationRequest['status'] | null) { + return status !== 'queued' && status !== 'started' && status !== 'removed'; +} + +function buildOutgoingMessageText(text: string, attachments: ChatComposerAttachment[]) { + const trimmedText = text.trim(); + const attachmentBlock = buildAttachmentMessageBlock(attachments); + + if (trimmedText && attachmentBlock) { + return `${trimmedText}\n\n${attachmentBlock}`; + } + + return trimmedText || attachmentBlock; +} + +function mergeComposerAttachments(previous: ChatComposerAttachment[], next: ChatComposerAttachment[]) { + const seen = new Set(previous.map((item) => item.path)); + + return [ + ...previous, + ...next.filter((item) => { + if (seen.has(item.path)) { + return false; + } + + seen.add(item.path); + return true; + }), + ]; +} + +function clearLegacyChatMessageStorage() { + if (typeof window === 'undefined') { + return; + } + + // Only clear the truly legacy single-key cache. Per-session keys use the + // same prefix and must be preserved across remounts. + window.localStorage.removeItem('main-chat-panel:messages'); +} + +function normalizePreviewUrl(value: string) { + return normalizeChatResourceUrl(value); +} + +function classifyPreviewKind(url: string): PreviewKind { + const pathname = url.toLowerCase().split('?')[0] ?? ''; + + if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/i.test(pathname)) { + return 'image'; + } + + if (/\.(mp4|webm|mov|m4v|ogg)$/i.test(pathname)) { + return 'video'; + } + + if (/\.(md|markdown)$/i.test(pathname)) { + return 'markdown'; + } + + if (/\.(ts|tsx|js|jsx|json|css|scss|html|xml|java|kt|sql|sh|py|go|rs|c|cpp|h|hpp|yml|yaml)$/i.test(pathname)) { + return 'code'; + } + + if (/\.(txt|log|csv)$/i.test(pathname)) { + return 'document'; + } + + if (/\.pdf$/i.test(pathname)) { + return 'pdf'; + } + + return 'file'; +} + +function buildPreviewLabel(url: string, source: PreviewItem['source']) { + try { + const parsed = new URL(url); + const lastSegment = parsed.pathname.split('/').filter(Boolean).at(-1); + + if (lastSegment) { + return source === 'context' ? `ํ˜„์žฌ ํ™”๋ฉด ยท ${lastSegment}` : lastSegment; + } + + return source === 'context' ? 'ํ˜„์žฌ ํ™”๋ฉด ๋ฏธ๋ฆฌ๋ณด๊ธฐ' : parsed.hostname; + } catch { + return source === 'context' ? 'ํ˜„์žฌ ํ™”๋ฉด ๋ฏธ๋ฆฌ๋ณด๊ธฐ' : url; + } +} + +function extractPreviewItems(messages: ChatMessage[]) { + const urlPattern = /(https?:\/\/[^\s)]+|\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+)/g; + const seen = new Set(); + const items: PreviewItem[] = []; + const orderedMessages = [...messages].reverse(); + + orderedMessages.forEach((message) => { + const matches = message.text.match(urlPattern) ?? []; + + matches.forEach((matchedUrl) => { + const normalizedUrl = normalizePreviewUrl(matchedUrl); + const kind = classifyPreviewKind(normalizedUrl); + + if (kind === 'file') { + return; + } + + if (seen.has(normalizedUrl)) { + return; + } + + seen.add(normalizedUrl); + items.push({ + id: `${message.id}-${normalizedUrl}`, + label: buildPreviewLabel(normalizedUrl, 'message'), + url: normalizedUrl, + kind, + source: 'message', + }); + }); + }); + + return items.slice(0, 12); +} + +function mapSystemStatusMessage(text: string) { + const normalized = text.trim(); + + if (!normalized) { + return null; + } + + if (normalized.startsWith('๋Œ€๊ธฐ์—ด ๋“ฑ๋ก:')) { + const matchedQueueCount = normalized.match(/๋Œ€๊ธฐ์—ด ๋“ฑ๋ก:\s*(\d+)๊ฑด/i); + return matchedQueueCount?.[1] ? `๋Œ€๊ธฐ์—ด ${matchedQueueCount[1]}๊ฑด` : '๋Œ€๊ธฐ์—ด ๋“ฑ๋ก๋จ'; + } + + if (normalized.startsWith('์š”์ฒญ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.') || normalized.startsWith('์ฆ‰์‹œ ์š”์ฒญ ์‹คํ–‰ ์ค‘์ž…๋‹ˆ๋‹ค.')) { + return '์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘'; + } + + if (normalized.startsWith('์ƒ๊ฐ ์ค‘์ž…๋‹ˆ๋‹ค.')) { + return '์š”์ฒญ ๋ถ„์„ ์ค‘'; + } + + if (normalized.includes('์š”์ฒญ ์˜๋„์™€ ํ˜„์žฌ ํ™”๋ฉด ๋ฌธ๋งฅ')) { + return '์š”์ฒญ ๋ถ„์„ ์ค‘'; + } + + if (normalized.includes('DB๋ฅผ ์ง์ ‘ ํ™•์ธ')) { + return 'DB ํ™•์ธ ์ค‘'; + } + + if (normalized.includes('API ๊ฒฝ๋กœ์™€ ์‹ค์ œ ์‘๋‹ต')) { + return 'API ํ™•์ธ ์ค‘'; + } + + if (normalized.includes('๊ด€๋ จ ์†Œ์Šค ํŒŒ์ผ๊ณผ ์—ฐ๊ฒฐ๋œ ํ๋ฆ„')) { + return '์†Œ์Šค ํ™•์ธ ์ค‘'; + } + + if ( + normalized.startsWith('์š”์ฒญ ์ฒ˜๋ฆฌ๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค.') || + normalized.startsWith('์ฆ‰์‹œ ์š”์ฒญ ์ฒ˜๋ฆฌ๊ฐ€ ๋๋‚ฌ์Šต๋‹ˆ๋‹ค.') || + normalized.startsWith('์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.') + ) { + return null; + } + + return null; +} + +function mapJobStatusLabel(item: Pick) { + if (!item.currentJobStatus) { + return ''; + } + + if (item.currentJobMessage?.trim()) { + return item.currentJobMessage.trim(); + } + + if (item.currentJobStatus === 'queued') { + return item.currentQueueSize > 0 ? `๋Œ€๊ธฐ์—ด ${item.currentQueueSize}๊ฑด` : '๋Œ€๊ธฐ ์ค‘'; + } + + if (item.currentJobStatus === 'started') { + return '์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘'; + } + + if (item.currentJobStatus === 'failed') { + return '์š”์ฒญ ์ฒ˜๋ฆฌ ์‹คํŒจ'; + } + + if (item.currentJobStatus === 'completed') { + return '์™„๋ฃŒ'; + } + + return ''; +} + +function isConversationProcessing(item: Pick) { + return item.currentJobStatus === 'queued' || item.currentJobStatus === 'started'; +} + +function buildRuntimeStatusLabel(snapshot: ChatRuntimeSnapshot | null, sessionId: string) { + if (!snapshot) { + return null; + } + + const current = snapshot.sessions.find((item) => item.sessionId === sessionId); + + if (!current) { + return null; + } + + if (current.runningCount > 0 && current.queuedCount > 0) { + return `์‹ค์ œ ์‹คํ–‰ ${current.runningCount}๊ฑด ยท ๋Œ€๊ธฐ ${current.queuedCount}๊ฑด`; + } + + if (current.runningCount > 0) { + return `์‹ค์ œ ์‹คํ–‰ ${current.runningCount}๊ฑด`; + } + + if (current.queuedCount > 0) { + return `์‹ค์ œ ๋Œ€๊ธฐ์—ด ${current.queuedCount}๊ฑด`; + } + + return null; +} + +export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = false }: MainChatPanelProps) { + const location = useLocation(); + const navigate = useNavigate(); + const { currentPage, focusedComponentId } = useAppStore(); + const appConfig = useAppConfig(); + const { hasAccess } = useTokenAccess(); + const { chatTypes } = useChatTypeRegistry(); + const [draft, setDraft] = useState(''); + const [composerAttachments, setComposerAttachments] = useState([]); + const [isComposerAttachmentUploading, setIsComposerAttachmentUploading] = useState(false); + const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); + const availableChatTypes = useMemo( + () => chatTypes.filter((item) => canUseChatType(item, userRoles)), + [chatTypes, userRoles], + ); + const chatTypeOptions = useMemo( + () => + chatTypes.map((item) => { + const isAllowed = canUseChatType(item, userRoles); + + return { + value: item.id, + label: item.name, + description: item.description, + isTemplate: item.isTemplate, + disabled: !isAllowed, + }; + }), + [chatTypes, userRoles], + ); + const [selectedChatTypeId, setSelectedChatTypeId] = useState(availableChatTypes[0]?.id ?? null); + const selectedChatType = availableChatTypes.find((item) => item.id === selectedChatTypeId) ?? null; + const requestedSessionId = getSessionIdFromSearch(location.search); + const requestedRuntimeLogRequestId = getRuntimeLogRequestIdFromSearch(location.search); + const [activeSessionId, setActiveSessionId] = useState(''); + const sessionMessageCacheRef = useRef>(new Map()); + const [isMobileConversationView, setIsMobileConversationView] = useState(false); + const [isConversationPaneClosed, setIsConversationPaneClosed] = useState(true); + const [editingConversationTitle, setEditingConversationTitle] = useState(''); + const [isEditingConversationTitle, setIsEditingConversationTitle] = useState(false); + const [isMobileViewport, setIsMobileViewport] = useState(false); + const [messages, setMessages] = useState([]); + const [activeView, setActiveView] = useState(initialView === 'errors' ? 'errors' : 'chat'); + const [copiedMessageId, setCopiedMessageId] = useState(null); + const [requestItemsState, setRequestItemsState] = useState([]); + const [pendingDeleteSessionId, setPendingDeleteSessionId] = useState(null); + const [isConversationContentLoading, setIsConversationContentLoading] = useState(true); + const [conversationLoadingLabel, setConversationLoadingLabel] = useState('๋Œ€ํ™” ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + const [isDeferringAuxiliaryChatRequests, setIsDeferringAuxiliaryChatRequests] = useState(false); + const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [, setOldestLoadedMessageId] = useState(null); + const [isLoadingOlderMessages, setIsLoadingOlderMessages] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const [isResourceStripOpen, setIsResourceStripOpen] = useState(false); + const [isTitleClusterOpen, setIsTitleClusterOpen] = useState(false); + const [notificationToggleSessionId, setNotificationToggleSessionId] = useState(null); + const [renamingConversationSessionId, setRenamingConversationSessionId] = useState(null); + const [messageApi, messageContextHolder] = message.useMessage(); + const [pendingContextConfirm, setPendingContextConfirm] = useState(null); + const viewportRef = useRef(null); + const composerRef = useRef(null); + const titleClusterRef = useRef(null); + const copyFeedbackTimerRef = useRef(null); + const pendingRequestsRef = useRef([]); + const messagesRef = useRef(messages); + const requestItemsRef = useRef(requestItemsState); + const loadOlderMessagesRef = useRef<() => void | Promise>(() => {}); + const previousConnectionStateRef = useRef<'connecting' | 'connected' | 'disconnected'>('connecting'); + const shouldRestoreConversationAfterReconnectRef = useRef(false); + const handledRequestedSessionIdRef = useRef(''); + const lastChatTypeSessionIdRef = useRef(''); + const notifiedTerminalJobKeysRef = useRef([]); + const lastMarkedReadResponseIdBySessionRef = useRef>({}); + const requestItems = Array.isArray(requestItemsState) ? requestItemsState : []; + const setRequestItems = useCallback((next: SetStateAction) => { + setRequestItemsState((previous) => { + const safePrevious = Array.isArray(previous) ? previous : []; + const resolved = + typeof next === 'function' + ? (next as (items: ChatConversationRequest[]) => ChatConversationRequest[])(safePrevious) + : next; + + return Array.isArray(resolved) ? resolved : []; + }); + }, []); + + const currentContext: ChatViewContext = { + pageId: currentPage.id, + pageTitle: currentPage.title, + topMenu: currentPage.topMenu, + focusedComponentId, + pageUrl: typeof window !== 'undefined' ? window.location.href : '', + isStandaloneMode: isStandaloneDisplayMode(), + pageVisibilityState: + typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + chatTypeId: selectedChatType?.id ?? null, + chatTypeLabel: selectedChatType?.name ?? '', + chatTypeDescription: selectedChatType?.description ?? '', + chatTypeIsTemplate: selectedChatType?.isTemplate ?? false, + }; + const { + conversationItems, + setConversationItems, + isConversationListLoading, + reloadConversationItems, + conversationSearch, + setConversationSearch, + } = useConversationListController({ + requestedSessionId: requestedSessionId ?? '', + }); + const conversationItemsRef = useRef(conversationItems); + const { + runtimeSnapshot, + runtimeJobDetail, + handleRuntimeEvent, + handleRuntimeDetailEvent, + } = useRuntimeController({ + activeSessionId, + isDeferringAuxiliaryChatRequests, + }); + const handleToggleConversationNotification = async () => { + if (!activeConversation) { + return; + } + + const sessionId = activeConversation.sessionId; + const nextNotifyOffline = !activeConversation.notifyOffline; + setNotificationToggleSessionId(sessionId); + setConversationItems((previous) => + previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, notifyOffline: nextNotifyOffline } : entry)), + ); + + try { + const item = await chatGateway.updateConversation(sessionId, { + notifyOffline: nextNotifyOffline, + }); + setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry))); + messageApi.success(nextNotifyOffline ? '์ฑ„ํŒ…๋ฐฉ ์•Œ๋ฆผ์„ ์ผฐ์Šต๋‹ˆ๋‹ค.' : '์ฑ„ํŒ…๋ฐฉ ์•Œ๋ฆผ์„ ๊ป์Šต๋‹ˆ๋‹ค.'); + } catch (error) { + setConversationItems((previous) => + previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, notifyOffline: !nextNotifyOffline } : entry)), + ); + messageApi.error(error instanceof Error ? error.message : '์ฑ„ํŒ…๋ฐฉ ์•Œ๋ฆผ ์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setNotificationToggleSessionId((current) => (current === sessionId ? null : current)); + } + }; + const handleCreateConversation = async () => { + const sessionId = createConversationSessionId(); + + try { + const item = await chatGateway.createConversation({ + sessionId, + title: '์ƒˆ ๋Œ€ํ™”', + contextLabel: selectedChatType?.name, + contextDescription: selectedChatType?.description, + notifyOffline: true, + }); + + setConversationItems((previous) => [item, ...previous.filter((entry) => entry.sessionId !== item.sessionId)]); + openConversationSession(item.sessionId); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '์ƒˆ ๋Œ€ํ™”๋ฅผ ๋งŒ๋“ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } + }; + const upsertRequestItem = (request: ChatConversationRequest) => { + setRequestItems((previous) => { + const existingIndex = previous.findIndex( + (item) => item.sessionId === request.sessionId && item.requestId === request.requestId, + ); + + if (existingIndex < 0) { + return [...previous, request]; + } + + const nextItems = [...previous]; + nextItems[existingIndex] = { + ...nextItems[existingIndex], + ...request, + }; + return nextItems; + }); + }; + const syncConversationPreviewForRequest = (sessionId: string, text: string, requestedAt = new Date().toISOString()) => { + const nextPreview = createConversationPreviewText(text); + + if (!nextPreview) { + return; + } + + setConversationItems((previous) => + previous.map((item) => + item.sessionId === sessionId + ? { + ...item, + title: buildConversationTitleFromRequestText(text, item.title), + lastMessagePreview: nextPreview, + lastMessageAt: requestedAt, + updatedAt: requestedAt, + } + : item, + ), + ); + }; + const syncConversationDetailIntoState = useCallback( + (sessionId: string, detail: ChatConversationDetailResponse) => { + setConversationItems((previous) => { + const exists = previous.some((item) => item.sessionId === sessionId); + + if (!exists) { + return [detail.item, ...previous]; + } + + return previous.map((item) => (item.sessionId === sessionId ? detail.item : item)); + }); + + sessionMessageCacheRef.current.set(sessionId, detail.messages); + + setRequestItems((previous) => { + const preserved = previous.filter((item) => item.sessionId !== sessionId); + return [...preserved, ...detail.requests]; + }); + + if (sessionId === activeSessionId) { + setMessages(detail.messages); + setHasOlderMessages(detail.hasOlderMessages); + setOldestLoadedMessageId(detail.oldestLoadedMessageId); + } + }, + [activeSessionId, setConversationItems, setHasOlderMessages, setMessages, setOldestLoadedMessageId, setRequestItems], + ); + + const syncConversationFromServer = useCallback( + async (sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + try { + const detail = await chatGateway.getConversationDetail(normalizedSessionId, { + limit: normalizedSessionId === activeSessionId ? Math.max(20, messagesRef.current.length || 0) : 20, + }); + syncConversationDetailIntoState(normalizedSessionId, detail); + } catch { + // Ignore background resync failures. + } + }, + [activeSessionId, syncConversationDetailIntoState], + ); + + const handleJobEvent = (event: ChatJobEvent, eventSessionId = activeSessionId) => { + const sessionId = eventSessionId.trim() || activeSessionId; + const existingRequest = + requestItemsRef.current.find((item) => item.sessionId === sessionId && item.requestId === event.requestId) ?? null; + + if (event.status === 'completed') { + pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== event.requestId); + } else if (event.status === 'failed') { + updatePendingMessageStatus(event.requestId, 'failed', 0); + } + + setConversationItems((previous) => + previous.map((item) => + item.sessionId === sessionId + ? { + ...item, + currentRequestId: event.requestId, + currentJobStatus: event.status, + currentJobMessage: event.message, + currentQueueSize: event.status === 'queued' ? event.queueSize : 0, + currentStatusUpdatedAt: new Date().toISOString(), + } + : item, + ), + ); + + upsertRequestItem({ + sessionId, + requestId: event.requestId, + status: event.status, + statusMessage: event.message, + userMessageId: existingRequest?.userMessageId ?? null, + userText: existingRequest?.userText ?? '', + responseMessageId: existingRequest?.responseMessageId ?? null, + responseText: existingRequest?.responseText ?? '', + hasResponse: existingRequest?.hasResponse ?? false, + canDelete: event.status !== 'queued' && event.status !== 'started' && !(existingRequest?.hasResponse), + createdAt: existingRequest?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + answeredAt: existingRequest?.answeredAt ?? null, + terminalAt: event.status === 'completed' || event.status === 'failed' ? new Date().toISOString() : null, + }); + + if (event.status !== 'completed' && event.status !== 'failed') { + return; + } + + const isDocumentHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'; + + const notificationKey = `${sessionId}:${event.requestId}:${event.status}`; + + if (notifiedTerminalJobKeysRef.current.includes(notificationKey)) { + return; + } + + notifiedTerminalJobKeysRef.current = [...notifiedTerminalJobKeysRef.current, notificationKey].slice(-80); + + const conversationTitle = + activeConversation?.sessionId === sessionId + ? activeConversation.title + : conversationItemsRef.current.find((item) => item.sessionId === sessionId)?.title || 'ํ˜„์žฌ ์ฑ„ํŒ…๋ฐฉ'; + const toastContent = + event.status === 'completed' + ? `${conversationTitle} ์‘๋‹ต์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.` + : `${conversationTitle} ์š”์ฒญ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.`; + + if (!isDocumentHidden) { + messageApi.open({ + key: notificationKey, + type: event.status === 'completed' ? 'success' : 'error', + content: toastContent, + duration: event.status === 'completed' ? 3 : 4, + }); + } + + window.setTimeout(() => { + void syncConversationFromServer(sessionId); + }, event.status === 'completed' ? 700 : 250); + }; + const handleIncomingMessageEvent = (incomingMessage: ChatMessage, eventSessionId = activeSessionId) => { + const sessionId = eventSessionId.trim() || activeSessionId; + + if (incomingMessage.clientRequestId) { + const existing = + requestItemsRef.current.find( + (item) => item.sessionId === sessionId && item.requestId === incomingMessage.clientRequestId, + ) ?? null; + const hasMeaningfulResponse = + incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text); + + upsertRequestItem({ + sessionId, + requestId: incomingMessage.clientRequestId, + status: + incomingMessage.author === 'codex' + ? existing?.status === 'completed' + ? 'completed' + : 'started' + : existing?.status ?? 'accepted', + statusMessage: existing?.statusMessage ?? null, + userMessageId: + incomingMessage.author === 'user' + ? incomingMessage.id + : existing?.userMessageId ?? null, + userText: + incomingMessage.author === 'user' + ? incomingMessage.text + : existing?.userText ?? '', + responseMessageId: + incomingMessage.author === 'codex' + ? incomingMessage.id + : existing?.responseMessageId ?? null, + responseText: + incomingMessage.author === 'codex' + ? incomingMessage.text + : existing?.responseText ?? '', + hasResponse: hasMeaningfulResponse || existing?.hasResponse === true, + canDelete: + incomingMessage.author !== 'codex' && + !(existing?.hasResponse === true) && + isRequestDeletionAllowed(existing?.status), + createdAt: existing?.createdAt ?? new Date().toISOString(), + updatedAt: new Date().toISOString(), + answeredAt: + incomingMessage.author === 'codex' + ? existing?.answeredAt ?? new Date().toISOString() + : existing?.answeredAt ?? null, + terminalAt: existing?.terminalAt ?? null, + }); + } + + sessionMessageCacheRef.current.set( + sessionId, + upsertChatMessage(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId), incomingMessage), + ); + + const hasMeaningfulCodexResponse = + incomingMessage.author === 'codex' && !isPreparingChatReplyText(incomingMessage.text); + const isForegroundSession = isActiveChatSessionInForeground({ + sessionId, + activeSessionId, + activeView, + isConversationPaneClosed, + isMobileViewport, + isMobileConversationView, + }); + const responseTimestamp = new Date().toISOString(); + + setConversationItems((previous) => + previous.map((item) => + item.sessionId === sessionId + ? { + ...item, + title: + incomingMessage.author === 'user' + ? buildConversationTitleFromRequestText(incomingMessage.text, item.title) + : item.title, + lastMessagePreview: createConversationPreviewText(incomingMessage.text), + lastMessageAt: responseTimestamp, + updatedAt: responseTimestamp, + hasUnreadResponse: + hasMeaningfulCodexResponse && !isForegroundSession ? true : item.hasUnreadResponse, + currentRequestId: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentRequestId, + currentJobStatus: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentJobStatus, + currentJobMessage: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? null + : item.currentJobMessage, + currentQueueSize: + incomingMessage.author === 'codex' && incomingMessage.clientRequestId + ? 0 + : item.currentQueueSize, + } + : item, + ), + ); + + const eventConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null; + + if (incomingMessage.author !== 'codex' || eventConversation?.notifyOffline !== true) { + return; + } + + if ( + isActiveChatSessionInForeground({ + sessionId, + activeSessionId, + activeView, + isConversationPaneClosed, + isMobileViewport, + isMobileConversationView, + }) + ) { + return; + } + }; + const shouldBlockConversationWhileLoading = useCallback( + (sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return false; + } + + const cachedMessages = getCachedSessionMessages(sessionMessageCacheRef.current, normalizedSessionId); + + if (cachedMessages.length > 0) { + return false; + } + + const conversation = conversationItemsRef.current.find((item) => item.sessionId === normalizedSessionId) ?? null; + return !(conversation?.currentJobStatus === 'queued' || conversation?.currentJobStatus === 'started'); + }, + [], + ); + const { socketRef, connectionState } = chatConnectionGateway.useConnection({ + sessionId: activeSessionId, + currentContext, + setMessages, + onMessageEvent: handleIncomingMessageEvent, + onJobEvent: handleJobEvent, + onRuntimeEvent: handleRuntimeEvent, + onRuntimeDetailEvent: handleRuntimeDetailEvent, + }); + const { + errorLogs, + selectedErrorLog, + selectedErrorLogReferenceSummary, + activeErrorResource, + errorSourceSummary, + isLoadingErrorLogs, + errorLogLoadError, + isErrorDetailExpanded, + setSelectedErrorLogId, + setActiveErrorResourceUrl, + setIsErrorDetailExpanded, + loadErrorLogs, + } = useErrorLogs({ + activeView, + hasAccess, + }); + + const previewItems = useMemo( + () => extractPreviewItems(messages.filter((message) => !isActivityLogMessage(message))), + [messages], + ); + const isTabletAppLayout = isMobileViewport; + const chatMessages = useMemo( + () => messages.filter((message) => message.author !== 'system' || isActivityLogMessage(message)), + [messages], + ); + const chatMessageSyncKey = useMemo(() => buildMessageSyncKey(chatMessages), [chatMessages]); + const activeConversation = useMemo( + () => conversationItems.find((item) => item.sessionId === activeSessionId) ?? null, + [activeSessionId, conversationItems], + ); + const activeRuntimeStatus = useMemo( + () => buildRuntimeStatusLabel(runtimeSnapshot, activeSessionId), + [runtimeSnapshot, activeSessionId], + ); + const activeRuntimeQueuedRequestIds = useMemo(() => { + if (!runtimeSnapshot) { + return null; + } + + return new Set( + runtimeSnapshot.queued + .filter((item) => item.sessionId === activeSessionId) + .map((item) => item.requestId), + ); + }, [activeSessionId, runtimeSnapshot]); + const activeQueuedComposerRequests = useMemo( + () => { + const queuedItems = requestItems + .filter((item) => item.sessionId === activeSessionId && item.status === 'queued') + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + + if (!activeRuntimeQueuedRequestIds) { + return queuedItems; + } + + return queuedItems.filter((item) => activeRuntimeQueuedRequestIds.has(item.requestId)); + }, + [activeRuntimeQueuedRequestIds, activeSessionId, requestItems], + ); + const activeRequestItems = useMemo( + () => + requestItems + .filter((item) => item.sessionId === activeSessionId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)), + [activeSessionId, requestItems], + ); + const { + activeSystemStatus, + captureViewportRestoreSnapshot, + handleViewportScroll, + handleViewportTouchEnd, + handleViewportTouchMove, + handleViewportTouchStart, + isSystemStatusPending, + isPullToLoadArmed, + pendingViewportRestoreRef, + pullToLoadDistance, + queueViewportPrependRestore, + scrollViewportToBottom, + setActiveSystemStatus, + setIsSystemStatusPending, + setShowScrollToBottom, + shouldStickToBottomRef, + showScrollToBottom, + } = useConversationViewportController({ + activeConversation, + activeQueuedComposerRequestsCount: activeQueuedComposerRequests.length, + activeRuntimeStatus, + chatMessageCount: chatMessages.length, + chatMessageSyncKey, + connectionState, + hasOlderMessages, + isConversationContentLoading, + isLoadingOlderMessages, + messages, + onLoadOlderMessages: () => loadOlderMessagesRef.current(), + runtimeSnapshot, + viewportRef, + mapJobStatusLabel, + mapSystemStatusMessage, + isActivityLogMessage, + }); + const { loadOlderMessages } = useConversationRoomController({ + activeSessionId, + connectionState, + shouldBlockConversationWhileLoading, + captureViewportRestoreSnapshot, + sessionMessageCacheRef, + messagesRef, + pendingViewportRestoreRef, + shouldRestoreConversationAfterReconnectRef, + setConversationItems, + setMessages, + setRequestItems, + setConversationLoadingLabel, + setIsConversationContentLoading, + setIsDeferringAuxiliaryChatRequests, + setHasOlderMessages, + setOldestLoadedMessageId, + setIsLoadingOlderMessages, + queueViewportPrependRestore, + viewportRef, + }); + loadOlderMessagesRef.current = loadOlderMessages; + + useEffect(() => { + setSharedActiveConversationSnapshot({ + sessionId: activeSessionId, + title: activeConversation?.title || '', + }); + }, [activeConversation?.title, activeSessionId]); + + const activeRequestMap = useMemo( + () => + new Map( + activeRequestItems.map((item) => [item.requestId, item]), + ), + [activeRequestItems], + ); + const filteredConversationItems = useMemo(() => { + const keyword = conversationSearch.trim().toLowerCase(); + const sortedItems = [...conversationItems].sort(compareConversationItemsByLatestChat); + + if (!keyword) { + return sortedItems; + } + + return sortedItems.filter((item) => + [item.title, item.sessionId, item.lastMessagePreview, item.contextDescription] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(keyword)), + ); + }, [conversationItems, conversationSearch]); + const unreadConversationItems = useMemo( + () => filteredConversationItems.filter((item) => item.hasUnreadResponse), + [filteredConversationItems], + ); + const processingConversationItems = useMemo( + () => filteredConversationItems.filter((item) => isConversationProcessing(item) && !item.hasUnreadResponse), + [filteredConversationItems], + ); + const generalConversationItems = useMemo( + () => filteredConversationItems.filter((item) => !item.hasUnreadResponse && !isConversationProcessing(item)), + [filteredConversationItems], + ); + const pendingDeleteConversation = + conversationItems.find((item) => item.sessionId === pendingDeleteSessionId) ?? null; + const { + activePreview, + isPreviewLoading, + isPreviewModalOpen, + previewContentType, + previewError, + previewText, + setActivePreviewId, + setIsPreviewModalOpen, + } = useConversationViewController({ + activeSessionId, + activeView, + previewItems, + selectedChatTypeId: selectedChatType?.id ?? null, + composerRef, + sessionMessageCacheRef, + setActiveSystemStatus, + setComposerAttachments, + setCopiedMessageId, + setDraft, + setIsResourceStripOpen, + setIsSystemStatusPending, + setMessages, + }); + + const markConversationReadLocally = (sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + setConversationItems((previous) => + previous.map((item) => + item.sessionId === normalizedSessionId + ? { + ...item, + hasUnreadResponse: false, + } + : item, + ), + ); + }; + + const syncActiveConversationReadState = (sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return; + } + + markConversationReadLocally(normalizedSessionId); + void Promise.allSettled([ + chatGateway.markConversationRead(normalizedSessionId), + chatGateway.markRoomNotificationMessagesRead(normalizedSessionId), + chatGateway.dismissRoomNotifications(normalizedSessionId), + ]); + }; + + const renderConversationListItem = ( + item: ChatConversationSummary, + section: 'processing' | 'unread' | 'general' = 'general', + ) => { + const isUnread = item.hasUnreadResponse; + const isProcessing = isConversationProcessing(item); + const isUnreadSection = section === 'unread'; + + return ( +
+ +
+ ); + }; + + const replaceChatSessionInUrl = (sessionId: string) => { + const nextSessionId = sessionId.trim(); + const searchParams = new URLSearchParams(location.search); + const currentSessionId = searchParams.get('sessionId')?.trim() ?? ''; + + if (nextSessionId) { + if (currentSessionId === nextSessionId) { + return; + } + + searchParams.set('sessionId', nextSessionId); + } else { + if (!currentSessionId) { + return; + } + + searchParams.delete('sessionId'); + } + + const nextSearch = searchParams.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : '', + }, + { replace: true }, + ); + }; + + const clearRequestedRuntimeLogInUrl = useCallback(() => { + const searchParams = new URLSearchParams(location.search); + + if (!searchParams.has('runtimeRequestId') && !searchParams.has('chatView')) { + return; + } + + searchParams.delete('runtimeRequestId'); + searchParams.delete('chatView'); + + const nextSearch = searchParams.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : '', + }, + { replace: true }, + ); + }, [location.pathname, location.search, navigate]); + + const openConversationSession = (sessionId: string) => { + replaceChatSessionInUrl(sessionId); + + if (sessionId === activeSessionId && !isConversationPaneClosed) { + if (isMobileViewport) { + setIsMobileConversationView(true); + } + setActiveView('chat'); + return; + } + + chatConnectionGateway.resetLastReceivedEventId(sessionId); + setConversationLoadingLabel('๋Œ€ํ™” ๋‚ด์šฉ์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.'); + setIsConversationContentLoading(shouldBlockConversationWhileLoading(sessionId)); + setIsDeferringAuxiliaryChatRequests(true); + setHasOlderMessages(false); + setOldestLoadedMessageId(null); + setIsLoadingOlderMessages(false); + shouldStickToBottomRef.current = true; + setShowScrollToBottom(false); + setActiveSessionId(sessionId); + setIsConversationPaneClosed(false); + setActiveView('chat'); + + if (isMobileViewport) { + setIsMobileConversationView(true); + } + + setMessages(getCachedSessionMessages(sessionMessageCacheRef.current, sessionId)); + setActivePreviewId(null); + setIsPreviewModalOpen(false); + setActiveSystemStatus(null); + setIsSystemStatusPending(false); + setIsResourceStripOpen(false); + shouldStickToBottomRef.current = true; + setShowScrollToBottom(false); + }; + + useEffect(() => { + const requestedView = getRequestedChatViewFromSearch(location.search); + + if (requestedView) { + setActiveView(requestedView); + } + }, [location.search]); + + useEffect(() => { + if (!isTitleClusterOpen) { + return; + } + + const handleWindowPointerDown = (event: PointerEvent) => { + if (!(event.target instanceof Node)) { + return; + } + + if (titleClusterRef.current?.contains(event.target)) { + return; + } + + setIsTitleClusterOpen(false); + }; + + window.addEventListener('pointerdown', handleWindowPointerDown); + + return () => { + window.removeEventListener('pointerdown', handleWindowPointerDown); + }; + }, [isTitleClusterOpen]); + + useEffect(() => { + clearLegacyChatMessageStorage(); + }, []); + + useEffect(() => { + conversationItemsRef.current = conversationItems; + }, [conversationItems]); + + useEffect(() => { + emitChatConversationsUpdated(conversationItems); + }, [conversationItems]); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + requestItemsRef.current = requestItems; + }, [requestItems]); + + useEffect(() => { + if (!activeSessionId) { + return; + } + + sessionMessageCacheRef.current.set(activeSessionId, messages); + }, [messages]); + + useEffect(() => { + if ( + !isActiveChatSessionInForeground({ + sessionId: activeSessionId, + activeSessionId, + activeView, + isConversationPaneClosed, + isMobileViewport, + isMobileConversationView, + }) + ) { + return; + } + + if (isConversationContentLoading || !shouldStickToBottomRef.current) { + return; + } + + if (!messages.some((item) => item.author === 'codex' && !isPreparingChatReplyText(item.text))) { + return; + } + + syncActiveConversationReadState(activeSessionId); + }, [ + activeSessionId, + activeView, + isConversationContentLoading, + isConversationPaneClosed, + isMobileConversationView, + isMobileViewport, + messages, + ]); + + const updatePendingMessageStatus = ( + requestId: string, + status: 'retrying' | 'failed' | null, + retryCount = 0, + ) => { + setMessages((previous) => + previous.map((message) => + message.clientRequestId === requestId + ? { + ...message, + deliveryStatus: status, + retryCount, + } + : message, + ), + ); + }; + + const sendChatRequest = (socket: WebSocket, request: PendingChatRequest) => { + socket.send( + JSON.stringify({ + type: 'message:send', + payload: { + text: request.text, + chatTypeId: request.chatTypeId, + chatTypeLabel: request.chatTypeLabel, + chatTypeDescription: request.chatTypeDescription, + chatTypeIsTemplate: request.chatTypeIsTemplate, + requestId: request.requestId, + mode: request.mode, + }, + }), + ); + }; + + const { + cancelPendingRequest, + deleteStoredRequest, + handleDeleteConversation, + handleRenameConversation, + removeQueuedComposerRequest, + retryPendingRequest, + } = useConversationRoomActionsController({ + activeSessionId, + requestedSessionId: requestedSessionId ?? '', + conversationItems, + activeConversation, + editingConversationTitle, + isMobileViewport, + pendingRequestsRef, + sessionMessageCacheRef, + socketRef, + setConversationItems, + setMessages, + setRequestItems, + setActiveSessionId, + setDraft, + setComposerAttachments, + setCopiedMessageId, + setActivePreviewId, + setIsPreviewModalOpen, + setActiveSystemStatus, + setIsSystemStatusPending, + setIsResourceStripOpen, + setIsConversationPaneClosed, + setIsMobileConversationView, + setRenamingConversationSessionId, + setEditingConversationTitle, + setIsEditingConversationTitle, + updatePendingMessageStatus, + sendChatRequest, + createLocalMessage, + replaceChatSessionInUrl, + messageApi, + }); + + useEffect(() => { + if (connectionState !== 'connected') { + return; + } + + const socket = socketRef.current; + + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + + const activePendingRequests = pendingRequestsRef.current.filter( + (request) => request.sessionId === activeSessionId && !request.failed, + ); + + if (activePendingRequests.length === 0) { + return; + } + + pendingRequestsRef.current = pendingRequestsRef.current.flatMap((request) => { + if (request.sessionId !== activeSessionId || request.failed) { + return [request]; + } + + try { + sendChatRequest(socket, request); + updatePendingMessageStatus(request.requestId, null, request.retryCount); + return []; + } catch { + const nextRetryCount = request.retryCount + 1; + + if (nextRetryCount >= CHAT_MAX_RETRY_ATTEMPTS) { + updatePendingMessageStatus(request.requestId, 'failed', nextRetryCount); + return [{ ...request, retryCount: nextRetryCount, failed: true }]; + } + + updatePendingMessageStatus(request.requestId, 'retrying', nextRetryCount); + return [{ ...request, retryCount: nextRetryCount }]; + } + }); + }, [activeSessionId, connectionState, socketRef]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia('(max-width: 1180px)'); + const syncViewport = () => { + const matches = mediaQuery.matches; + setIsMobileViewport(matches); + setIsMobileConversationView(matches ? false : true); + setIsConversationPaneClosed(false); + }; + + syncViewport(); + mediaQuery.addEventListener('change', syncViewport); + + return () => { + mediaQuery.removeEventListener('change', syncViewport); + }; + }, []); + + useEffect(() => { + const hasSessionChanged = lastChatTypeSessionIdRef.current !== activeSessionId; + lastChatTypeSessionIdRef.current = activeSessionId; + + if (activeSessionId) { + if (hasSessionChanged) { + const lastUsedChatTypeId = getStoredChatSessionLastTypeId(activeSessionId); + + if (lastUsedChatTypeId && availableChatTypes.some((item) => item.id === lastUsedChatTypeId)) { + if (selectedChatTypeId !== lastUsedChatTypeId) { + setSelectedChatTypeId(lastUsedChatTypeId); + } + return; + } + + const defaultChatTypeId = availableChatTypes[0]?.id ?? null; + + if (selectedChatTypeId !== defaultChatTypeId) { + setSelectedChatTypeId(defaultChatTypeId); + } + return; + } + } + + if (selectedChatTypeId && availableChatTypes.some((item) => item.id === selectedChatTypeId)) { + return; + } + + setSelectedChatTypeId(availableChatTypes[0]?.id ?? null); + }, [activeSessionId, availableChatTypes, selectedChatTypeId]); + + useEffect(() => { + if (!activeSessionId || !selectedChatTypeId) { + return; + } + + setStoredChatSessionLastTypeId(activeSessionId, selectedChatTypeId); + }, [activeSessionId, selectedChatTypeId]); + + useEffect(() => { + const nextView = initialView === 'errors' ? 'errors' : 'chat'; + + if (nextView === 'errors' && !hasAccess) { + setActiveView('chat'); + return; + } + + setActiveView(nextView); + }, [hasAccess, initialView]); + + useEffect(() => { + return () => { + if (copyFeedbackTimerRef.current !== null) { + window.clearTimeout(copyFeedbackTimerRef.current); + } + }; + }, []); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + + const { body, documentElement } = document; + const previousBodyOverflow = body.style.overflow; + const previousHtmlOverflow = documentElement.style.overflow; + const shouldLockOuterScroll = isMaximized || (lockOuterScrollOnMobile && isMobileViewport); + + if (shouldLockOuterScroll) { + body.style.overflow = 'hidden'; + documentElement.style.overflow = 'hidden'; + } + + return () => { + body.style.overflow = previousBodyOverflow; + documentElement.style.overflow = previousHtmlOverflow; + }; + }, [isMaximized, isMobileViewport, lockOuterScrollOnMobile]); + + useEffect(() => { + setEditingConversationTitle(activeConversation?.title ?? ''); + }, [activeConversation?.sessionId, activeConversation?.title]); + + useEffect(() => { + if (!requestedSessionId) { + handledRequestedSessionIdRef.current = ''; + return; + } + + if (isConversationListLoading) { + return; + } + + const requestedConversationExists = conversationItems.some((item) => item.sessionId === requestedSessionId); + + if (!requestedConversationExists) { + return; + } + + if (requestedSessionId === activeSessionId && handledRequestedSessionIdRef.current === requestedSessionId) { + return; + } + + handledRequestedSessionIdRef.current = requestedSessionId; + + if (requestedSessionId === activeSessionId) { + if (isMobileViewport && !isConversationPaneClosed) { + setIsMobileConversationView(true); + } + if (!isConversationPaneClosed) { + setActiveView('chat'); + } + return; + } + + openConversationSession(requestedSessionId); + }, [activeSessionId, conversationItems, isConversationListLoading, isConversationPaneClosed, isMobileViewport, requestedSessionId]); + + useEffect(() => { + if (requestedSessionId) { + return; + } + + if (conversationItems.length === 0) { + setActiveSessionId(''); + setMessages([]); + setRequestItems([]); + setIsConversationPaneClosed(false); + setIsMobileConversationView(false); + return; + } + + if (activeSessionId) { + return; + } + }, [activeSessionId, conversationItems, isMobileViewport, requestedSessionId]); + + useEffect(() => { + const previousConnectionState = previousConnectionStateRef.current; + + if (previousConnectionState === 'connected' && connectionState !== 'connected') { + shouldRestoreConversationAfterReconnectRef.current = true; + } + + previousConnectionStateRef.current = connectionState; + }, [connectionState]); + + useEffect(() => { + if (connectionState !== 'connected') { + return; + } + + if (isDeferringAuxiliaryChatRequests) { + return; + } + + let isCancelled = false; + + if (!activeSessionId.trim()) { + void chatGateway.listConversations().then((items) => { + if (!isCancelled) { + setConversationItems(items); + } + }).catch(() => { + // ์žฌ์—ฐ๊ฒฐ ์งํ›„ ๋ชฉ๋ก ์žฌ์กฐํšŒ ์‹คํŒจ๋Š” ํ˜„์žฌ ๋ชฉ๋ก ์ƒํƒœ๋ฅผ ์œ ์ง€ํ•œ๋‹ค. + }); + } + + return () => { + isCancelled = true; + }; + }, [activeSessionId, connectionState, isDeferringAuxiliaryChatRequests]); + + useEffect(() => { + if (connectionState !== 'disconnected') { + return; + } + + setConversationItems((previous) => + previous.map((item) => ({ + ...item, + currentRequestId: null, + currentJobStatus: null, + currentJobMessage: null, + currentQueueSize: 0, + })), + ); + }, [connectionState]); + + useEffect(() => { + if (!activeSessionId) { + return; + } + + setConversationItems((previous) => + previous.map((item) => { + if (item.sessionId !== activeSessionId) { + return item; + } + + const latestMessage = getLatestConversationPreviewMessage(messages); + const nextPreview = latestMessage ? createConversationPreviewText(latestMessage.text) : item.lastMessagePreview; + + return { + ...item, + title: buildConversationTitleFromMessages(messages, item.title), + lastMessagePreview: nextPreview, + lastMessageAt: latestMessage ? new Date().toISOString() : item.lastMessageAt, + }; + }), + ); + }, [activeSessionId, messages]); + + useEffect(() => { + if (!activeConversation) { + return; + } + + if ( + !isActiveChatSessionInForeground({ + sessionId: activeConversation.sessionId, + activeSessionId, + activeView, + isConversationPaneClosed, + isMobileViewport, + isMobileConversationView, + }) + ) { + return; + } + + if (isConversationContentLoading || !shouldStickToBottomRef.current) { + return; + } + + const latestResponseMessageId = requestItems.reduce( + (maxId, item) => + item.sessionId === activeConversation.sessionId && item.hasResponse && item.responseMessageId != null + ? Math.max(maxId, item.responseMessageId) + : maxId, + 0, + ); + + if (latestResponseMessageId <= 0) { + return; + } + + if (!messages.some((item) => item.id === latestResponseMessageId && item.author === 'codex')) { + return; + } + + if (lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId] === latestResponseMessageId) { + return; + } + + lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId] = latestResponseMessageId; + setConversationItems((previous) => + previous.map((item) => + item.sessionId === activeConversation.sessionId + ? { + ...item, + hasUnreadResponse: false, + } + : item, + ), + ); + + void chatGateway.markConversationRead(activeConversation.sessionId).catch(() => { + delete lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId]; + }); + }, [ + activeConversation, + activeSessionId, + activeView, + isConversationContentLoading, + isConversationPaneClosed, + isMobileConversationView, + isMobileViewport, + messages, + requestItems, + ]); + + const { + executeSendMessage, + handleComposerFilesPicked, + handleSend, + handleSendImmediate, + } = useConversationComposerController({ + activeSessionId, + appConfigChat: appConfig.chat, + draft, + composerAttachments, + isComposerAttachmentUploading, + selectedChatType, + socketRef, + composerRef, + messagesRef, + pendingRequestsRef, + shouldStickToBottomRef, + setDraft, + setComposerAttachments, + setIsComposerAttachmentUploading, + setMessages, + setActiveSystemStatus, + setIsSystemStatusPending, + setShowScrollToBottom, + setPendingContextConfirm, + setStoredChatSessionLastTypeId, + upsertRequestItem, + syncConversationPreviewForRequest, + updatePendingMessageStatus, + createLocalMessage, + createChatMessage, + createActivityLogPlaceholder, + buildOutgoingMessageText, + summarizeRecentContext, + mergeComposerAttachments, + sendChatRequest, + scrollViewportToBottom, + }); + + const handleCopyMessage = async (message: ChatMessage) => { + await copyText(message.text); + setCopiedMessageId(message.id); + + if (copyFeedbackTimerRef.current !== null) { + window.clearTimeout(copyFeedbackTimerRef.current); + } + + copyFeedbackTimerRef.current = window.setTimeout(() => { + setCopiedMessageId((current) => (current === message.id ? null : current)); + copyFeedbackTimerRef.current = null; + }, 2000); + }; + + if (activeView === 'errors' && !hasAccess) { + return ( + ์—๋Ÿฌ ๋กœ๊ทธ} + className="app-chat-panel" + styles={{ body: { height: '100%' } }} + > + + + ); + } + + return ( + <> + {messageContextHolder} + +
+ + + + {hasAccess ? ( + + ) : null} +
+
+
+
+ {activeView === 'chat' ? ( + isEditingConversationTitle && activeConversation ? ( + { + setEditingConversationTitle(event.target.value); + }} + onPressEnter={() => { + void handleRenameConversation(); + }} + onBlur={() => { + void handleRenameConversation(); + }} + /> + ) : ( + {activeConversation?.title || 'Codex Chat'} + ) + ) : activeView === 'runtime' ? ( + Codex Live ๋Ÿฐํƒ€์ž„ + ) : ( + ์—๋Ÿฌ ๋กœ๊ทธ + )} +
+
+
+ + } + extra={ + activeView === 'errors' || activeView === 'runtime' ? null : ( + + {activeConversation ? ( + + + + ) : null + } + onCancel={() => { + setIsPreviewModalOpen(false); + }} + width={960} + zIndex={1600} + className="app-chat-panel__preview-modal" + > + {activePreview ? ( +
+
+ + }>{activePreview.kind} + {activePreview.source === 'context' ? 'ํ˜„์žฌ ํ™”๋ฉด' : '์ฑ„ํŒ… ๊ฒฐ๊ณผ'} + +
+
+ +
+
+ ) : ( + + )} + + { + setPendingDeleteSessionId(null); + }} + onOk={async () => { + const targetSessionId = pendingDeleteSessionId; + + if (!targetSessionId) { + return; + } + + await handleDeleteConversation(targetSessionId); + setPendingDeleteSessionId(null); + }} + > + + {pendingDeleteConversation?.title + ? `"${pendingDeleteConversation.title}" ๋Œ€ํ™”์™€ ์ €์žฅ๋œ ๋ฉ”์‹œ์ง€๊ฐ€ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.` + : '์ด ๋Œ€ํ™”์™€ ์ €์žฅ๋œ ๋ฉ”์‹œ์ง€๊ฐ€ ์‚ญ์ œ๋ฉ๋‹ˆ๋‹ค.'} + + +
+ + ); +} diff --git a/src/app/main/MainContent.tsx b/src/app/main/MainContent.tsx new file mode 100755 index 0000000..b98ef79 --- /dev/null +++ b/src/app/main/MainContent.tsx @@ -0,0 +1,328 @@ +import { CompressOutlined } from '@ant-design/icons'; +import { Button, Layout, Modal, Space, Typography } from 'antd'; +import { useMemo, useRef, useState, type ReactNode } from 'react'; +import { WindowUI, type WindowFrame } from '../../components/window'; +import { BoardPage } from '../../features/board'; +import { ComponentSamplesLayout } from '../../features/layout/component-sample-gallery'; +import { SampleWidgetsLayout } from '../../features/layout/widget-sample-gallery'; +import { HistoryPage } from '../../features/history'; +import { PlanBoardChartsPage, PlanBoardPage, PlanSchedulePage, ReleaseReviewPage } from '../../features/planBoard'; +import { useSearchLayer } from '../../layer'; +import { useAppStore } from '../../store'; +import { LayoutPlaygroundView } from '../../views/play/LayoutPlaygroundView'; +import { ChatTypeManagementPage } from './ChatTypeManagementPage'; +import { ChatSourceChangesPage } from './ChatSourceChangesPage'; +import { MainChatPanel } from './MainChatPanel'; +import { useMainLayoutContext } from './layout/MainLayoutContext'; +import { + HIDDEN_COMPONENT_IDS, + buildCascadeWindowFrames, + buildGridWindowFrames, + getDefaultWindowFrame, + getPlanStatusFromWindowSelection, +} from './mainContent/windowLayout'; +import type { MainContentProps } from './types'; + +const { Content } = Layout; +const { Paragraph, Text, Title } = Typography; + +export function MainContent({ + contentExpanded, + onToggleContentExpanded, + children, +}: MainContentProps) { + const { setFocusedComponentId } = useAppStore(); + const { windowSelections, clearWindowSelection } = useSearchLayer(); + const { + componentSampleEntries, + widgetSampleEntries, + componentSamples, + widgetSamples, + initialSelectedPlanId, + initialSelectedWorkId, + } = useMainLayoutContext(); + const stageRef = useRef(null); + const [windowFrames, setWindowFrames] = useState>({}); + const [windowZIndexes, setWindowZIndexes] = useState>({}); + const [isWindowLayoutModalOpen, setIsWindowLayoutModalOpen] = useState(false); + const sampleEntryMap = useMemo( + () => + new Map( + [...componentSamples, ...widgetSamples].map((entry) => [ + `${entry.modulePath.includes('/widgets/') ? 'widget' : 'component'}:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`, + entry, + ]), + ), + [componentSamples, widgetSamples], + ); + const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame => + windowFrames[instanceId] ?? getDefaultWindowFrame(selectionId, index); + + const getWindowZIndex = (instanceId: string, index: number) => windowZIndexes[instanceId] ?? index + 1; + + const activateWindow = (instanceId: string) => { + setWindowZIndexes((previous) => { + const highestZIndex = Math.max(0, ...Object.values(previous)); + return { + ...previous, + [instanceId]: highestZIndex + 1, + }; + }); + }; + + const getStageSize = () => { + const stage = stageRef.current; + + if (!stage) { + return null; + } + + return { + width: stage.clientWidth, + height: stage.clientHeight, + }; + }; + + const applyCascadeWindowLayout = () => { + const stageSize = getStageSize(); + + if (!stageSize) { + return; + } + + const nextFrames = buildCascadeWindowFrames(windowSelections, stageSize); + + setWindowFrames((previous) => ({ + ...previous, + ...nextFrames, + })); + setIsWindowLayoutModalOpen(false); + }; + + const applyGridWindowLayout = () => { + const stageSize = getStageSize(); + + if (!stageSize) { + return; + } + + const nextFrames = buildGridWindowFrames(windowSelections, stageSize); + + setWindowFrames((previous) => ({ + ...previous, + ...nextFrames, + })); + setIsWindowLayoutModalOpen(false); + }; + + const handleFocusCapture = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return; + } + + const focusedElement = target.closest('[data-focus-id]'); + setFocusedComponentId(focusedElement?.dataset.focusId ?? null); + }; + + const renderWindowSelectionContent = (selectionId: string, fallbackContent: ReactNode) => { + if (selectionId === 'page:apis:components') { + return ( + + ); + } + + if (selectionId === 'page:apis:widgets') { + return ; + } + + if (selectionId === 'page:plans:release') { + return ( + + ); + } + + if (selectionId === 'page:plans:board') { + return ; + } + + if (selectionId === 'page:plans:release-review') { + return ; + } + + if (selectionId === 'page:plans:charts') { + return ; + } + + if (selectionId === 'page:plans:schedule') { + return ; + } + + if (selectionId === 'page:plans:history') { + return ; + } + + const planStatus = getPlanStatusFromWindowSelection(selectionId); + + if (planStatus) { + return ( + + ); + } + + if (selectionId === 'page:chat:live') { + return ; + } + + if (selectionId === 'page:chat:errors') { + return ; + } + + if (selectionId === 'page:chat:changes') { + return ; + } + + if (selectionId === 'page:chat:manage') { + return ; + } + + if (selectionId === 'page:play:layout') { + return ; + } + + return fallbackContent; + }; + + return ( + { + handleFocusCapture(event.target); + }} + onFocusCapture={(event) => { + handleFocusCapture(event.target); + }} + > + {contentExpanded ? ( + + +
+ ๊ฐ€๋“ ์ฑ„์šฐ๊ธฐ + + ํ˜„์žฌ ์—ด๋ฆฐ ์ฐฝ์„ ์ž‘์—… ์˜์—ญ ๊ธฐ์ค€ ๊ทธ๋ฆฌ๋“œ๋กœ ๊ฝ‰ ์ฐจ๊ฒŒ ์žฌ๋ฐฐ์น˜ํ•ฉ๋‹ˆ๋‹ค. + + +
+ + +
+ ); +} diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx new file mode 100755 index 0000000..c317b83 --- /dev/null +++ b/src/app/main/MainHeader.tsx @@ -0,0 +1,3231 @@ +import { + ApiOutlined, + BellOutlined, + ClockCircleOutlined, + CopyOutlined, + DownloadOutlined, + FileMarkdownOutlined, + LoadingOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + ProfileOutlined, + ReloadOutlined, + SettingOutlined, +} from '@ant-design/icons'; +import { + Alert, + Button, + Checkbox, + Drawer, + Dropdown, + Grid, + Input, + InputNumber, + Layout, + Modal, + Progress, + Select, + Segmented, + Space, + Typography, +} from 'antd'; +import { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { fetchPlanItems } from '../../features/planBoard/api'; +import { isAutomationFailedItem, isReleasePendingMainItem, isWorkingPlanItem } from '../../features/planBoard/quickFilters'; +import { fetchServerCommands, restartServerCommand } from '../../features/serverCommand/api'; +import type { ServerCommandItem } from '../../features/serverCommand/types'; +import { + DEFAULT_APP_CONFIG, + saveAutomationNotificationPreferenceToServer, + saveAppConfigToServer, + setStoredAppConfig, + syncAppConfigFromServer, + useAppConfig, + type AppConfig, + type PlanCostTimeUnit, +} from './appConfig'; +import { applyAppUpdate, getAppUpdateSnapshot, subscribeAppUpdate, type AppUpdateStatus } from './appUpdate'; +import { + fetchWebPushConfig, + registerPwaNotificationToken, + registerWebPushSubscription, + unregisterPwaNotificationToken, + unregisterWebPushSubscription, + type WebPushSubscriptionPayload, +} from './notificationApi'; +import { + clearNotificationIdentity, + getSavedNotificationDeviceId, + getSavedPwaNotificationToken, + setSavedPwaNotificationToken, +} from './notificationIdentity'; +import { + ALLOWED_REGISTRATION_TOKEN, + setRegisteredAccessToken, + useTokenAccess, +} from './tokenAccess'; +import { chatConnectionGateway, chatGateway } from './chatV2'; +import { HeaderMessageCenter } from './HeaderMessageCenter'; +import { fetchChatRuntimeJobDetail } from './mainChatPanel'; +import { + getSharedActiveConversationSnapshot, + subscribeSharedActiveConversation, +} from './mainChatPanel/sharedActiveConversation'; +import { buildChatPath } from './routes'; +import type { MainHeaderProps } from './types'; +import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from './mainChatPanel/types'; + +const { Header } = Layout; +const { Paragraph, Text } = Typography; +const { useBreakpoint } = Grid; +const APP_SETTINGS_CATEGORIES = [ + { value: 'automation', label: '์ž‘์—…' }, + { value: 'workspace', label: '์ž‘์—… ํ™˜๊ฒฝ' }, +] as const; + +const APP_SETTINGS_SECTIONS: Array<{ + value: 'chatSettings' | 'planDefaults' | 'planCost' | 'worklogAutomation' | 'automationNotifications' | 'gestureShortcuts'; + label: string; + category: (typeof APP_SETTINGS_CATEGORIES)[number]['value']; +}> = [ + { value: 'chatSettings', label: '์ฑ„ํŒ… ๋ฌธ๋งฅ ์„ค์ •', category: 'workspace' }, + { value: 'planDefaults', label: '์ž๋™ํ™” ๊ธฐ๋ณธ๊ฐ’', category: 'automation' }, + { value: 'planCost', label: '๋น„์šฉ ํ‘œ์‹œ', category: 'automation' }, + { value: 'worklogAutomation', label: '์—…๋ฌด์ผ์ง€ ์ž๋™ํ™” ์„ค์ •', category: 'automation' }, + { value: 'automationNotifications', label: '์ž๋™ํ™” ์•Œ๋ฆผ', category: 'automation' }, + { value: 'gestureShortcuts', label: '์ œ์Šค์ฒ˜ / ๋‹จ์ถ•ํ‚ค', category: 'workspace' }, +]; + +const PLAN_COST_TIME_UNIT_LABELS: Record = { + hour: '์‹œ๊ฐ„', + minute: '๋ถ„', + second: '์ดˆ', +}; + +type ClientNotificationPermissionState = 'unsupported' | 'default' | 'granted' | 'denied'; +type SettingsModalKey = 'appSettings' | 'notification' | 'token' | 'update'; +type AppSettingsCategoryKey = (typeof APP_SETTINGS_CATEGORIES)[number]['value']; +type AppSettingsSectionKey = (typeof APP_SETTINGS_SECTIONS)[number]['value']; + +let lastPushSwRegisterAttempts: Array<{ url: string; mode: string; error: string }> = []; +type FeedbackTone = 'success' | 'info' | 'warning' | 'error'; +type InlineFeedback = { + tone: FeedbackTone; + message: string; +}; + +function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { + return left.maxContextMessages === right.maxContextMessages && left.maxContextChars === right.maxContextChars; +} + +function getChatSettingsDiffLabels(saved: AppConfig['chat'], draft: AppConfig['chat']) { + const changedLabels: string[] = []; + + if (saved.maxContextMessages !== draft.maxContextMessages) { + changedLabels.push('์ตœ๊ทผ ๋ฌธ๋งฅ ๋ฉ”์‹œ์ง€ ์ˆ˜'); + } + + if (saved.maxContextChars !== draft.maxContextChars) { + changedLabels.push('์ตœ๊ทผ ๋ฌธ๋งฅ ๊ธ€์ž ์ˆ˜'); + } + + return changedLabels; +} + +function areWorklogAutomationSettingsEqual(left: AppConfig['worklogAutomation'], right: AppConfig['worklogAutomation']) { + return ( + left.autoCreateDailyWorklog === right.autoCreateDailyWorklog && + left.dailyCreateTime === right.dailyCreateTime && + left.includeScreenshots === right.includeScreenshots && + left.includeChangedFiles === right.includeChangedFiles && + left.includeCommandLogs === right.includeCommandLogs && + left.template === right.template + ); +} + +function getWorklogAutomationDiffLabels(saved: AppConfig['worklogAutomation'], draft: AppConfig['worklogAutomation']) { + const changedLabels: string[] = []; + + if (saved.autoCreateDailyWorklog !== draft.autoCreateDailyWorklog) { + changedLabels.push('์ผ์ผ ์ž‘์—…์ผ์ง€ ์ž๋™ ์ƒ์„ฑ'); + } + + if (saved.dailyCreateTime !== draft.dailyCreateTime) { + changedLabels.push('์ž๋™ ์ƒ์„ฑ ์‹œ๊ฐ'); + } + + if (saved.includeScreenshots !== draft.includeScreenshots) { + changedLabels.push('์Šคํฌ๋ฆฐ์ƒท ํฌํ•จ'); + } + + if (saved.includeChangedFiles !== draft.includeChangedFiles) { + changedLabels.push('๋ณ€๊ฒฝ ํŒŒ์ผ ๋ชฉ๋ก ํฌํ•จ'); + } + + if (saved.includeCommandLogs !== draft.includeCommandLogs) { + changedLabels.push('์‹คํ–‰ ์ปค๋งจ๋“œ ํฌํ•จ'); + } + + if (saved.template !== draft.template) { + changedLabels.push('๊ธฐ๋ณธ ํ…œํ”Œ๋ฆฟ'); + } + + return changedLabels; +} + +function arePlanDefaultSettingsEqual(left: AppConfig['planDefaults'], right: AppConfig['planDefaults']) { + return ( + left.jangsingProcessingRequired === right.jangsingProcessingRequired && + left.autoDeployToMain === right.autoDeployToMain && + left.openEditorAfterCreate === right.openEditorAfterCreate + ); +} + +function getPlanDefaultDiffLabels(saved: AppConfig['planDefaults'], draft: AppConfig['planDefaults']) { + const changedLabels: string[] = []; + + if (saved.jangsingProcessingRequired !== draft.jangsingProcessingRequired) { + changedLabels.push('๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ ๊ธฐ๋ณธ๊ฐ’'); + } + + if (saved.autoDeployToMain !== draft.autoDeployToMain) { + changedLabels.push('๋ฉ”์ธ๊นŒ์ง€ ์ž๋™๋“ฑ๋ก'); + } + + if (saved.openEditorAfterCreate !== draft.openEditorAfterCreate) { + changedLabels.push('๋“ฑ๋ก ํ›„ ํŽธ์ง‘๊ธฐ ์—ด๊ธฐ'); + } + + return changedLabels; +} + +function arePlanCostSettingsEqual(left: AppConfig['planCost'], right: AppConfig['planCost']) { + return ( + left.baseCostPerMillionTokens === right.baseCostPerMillionTokens && + left.retryCostMultiplierPercent === right.retryCostMultiplierPercent && + left.hourlyCostMultiplierPercent === right.hourlyCostMultiplierPercent && + left.timeCostUnit === right.timeCostUnit && + left.attentionCostThresholdMultiplier === right.attentionCostThresholdMultiplier && + left.warningCostThresholdMultiplier === right.warningCostThresholdMultiplier && + left.highCostThresholdMultiplier === right.highCostThresholdMultiplier + ); +} + +function getPlanCostDiffLabels(saved: AppConfig['planCost'], draft: AppConfig['planCost']) { + const changedLabels: string[] = []; + + if (saved.baseCostPerMillionTokens !== draft.baseCostPerMillionTokens) { + changedLabels.push('๋ฐฑ๋งŒ ํ† ํฐ๋‹น ๊ธฐ์ค€ ๋น„์šฉ'); + } + + if (saved.retryCostMultiplierPercent !== draft.retryCostMultiplierPercent) { + changedLabels.push('์žฌ์ฒ˜๋ฆฌ ๊ฐ€์‚ฐ ๋น„์œจ'); + } + + if (saved.hourlyCostMultiplierPercent !== draft.hourlyCostMultiplierPercent) { + changedLabels.push('์‹œ๊ฐ„ ๊ฐ€์‚ฐ ๋น„์œจ'); + } + + if (saved.timeCostUnit !== draft.timeCostUnit) { + changedLabels.push('์‹œ๊ฐ„ ๊ฐ€์‚ฐ ๊ธฐ์ค€ ๋‹จ์œ„'); + } + + if (saved.attentionCostThresholdMultiplier !== draft.attentionCostThresholdMultiplier) { + changedLabels.push('๊ด€์‹ฌ ๊ตฌ๊ฐ„ ๊ธฐ์ค€'); + } + + if (saved.warningCostThresholdMultiplier !== draft.warningCostThresholdMultiplier) { + changedLabels.push('์ฃผ์˜ ๊ตฌ๊ฐ„ ๊ธฐ์ค€'); + } + + if (saved.highCostThresholdMultiplier !== draft.highCostThresholdMultiplier) { + changedLabels.push('๋†’์Œ ๊ตฌ๊ฐ„ ๊ธฐ์ค€'); + } + + return changedLabels; +} + +function formatCostAmount(value: number) { + return `${new Intl.NumberFormat('ko-KR').format(value)}์›`; +} + +function formatPlanCostTimeMultiplierLabel(planCost: AppConfig['planCost']) { + return `${PLAN_COST_TIME_UNIT_LABELS[planCost.timeCostUnit]}๋‹น ${planCost.hourlyCostMultiplierPercent}%`; +} + +function getPlanCostThresholdPreview(planCost: AppConfig['planCost']) { + const attentionThreshold = planCost.baseCostPerMillionTokens * Math.max(0.1, planCost.attentionCostThresholdMultiplier); + const warningThreshold = + planCost.baseCostPerMillionTokens * + Math.max(planCost.attentionCostThresholdMultiplier, planCost.warningCostThresholdMultiplier); + const highThreshold = + planCost.baseCostPerMillionTokens * Math.max(planCost.warningCostThresholdMultiplier, planCost.highCostThresholdMultiplier); + + return `์•ˆ์ • ${formatCostAmount(attentionThreshold)} ์ดํ•˜ ยท ๊ด€์‹ฌ ${formatCostAmount( + warningThreshold, + )} ์ดํ•˜ ยท ์ฃผ์˜ ${formatCostAmount(highThreshold)} ์ดํ•˜ ยท ๋†’์Œ ${formatCostAmount(highThreshold)} ์ดˆ๊ณผ`; +} + +function getAppSettingsSectionCategory(section: AppSettingsSectionKey): AppSettingsCategoryKey { + return APP_SETTINGS_SECTIONS.find((entry) => entry.value === section)?.category ?? 'automation'; +} + +function getAppSettingsSectionOptions(category: AppSettingsCategoryKey) { + return APP_SETTINGS_SECTIONS.filter((entry) => entry.category === category).map((entry) => ({ + label: entry.label, + value: entry.value, + })); +} + +function formatRuntimeTimestamp(value: string | null | undefined) { + if (!value) { + return '-'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); +} + +function formatRuntimeRelativeLabel(value: string | null | undefined) { + if (!value) { + return '-'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return formatRuntimeTimestamp(value); + } + + const diffMs = Date.now() - date.getTime(); + + if (diffMs <= 15_000) { + return '๋ฐฉ๊ธˆ'; + } + + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) { + return `${diffSeconds}์ดˆ ์ „`; + } + + const diffMinutes = Math.floor(diffSeconds / 60); + + if (diffMinutes < 60) { + return `${diffMinutes}๋ถ„ ์ „`; + } + + const diffHours = Math.floor(diffMinutes / 60); + + if (diffHours < 24) { + return `${diffHours}์‹œ๊ฐ„ ์ „`; + } + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}์ผ ์ „`; +} + +function buildRuntimeTerminalLabel(terminalStatus: ChatRuntimeJobDetail['terminalStatus']) { + if (!terminalStatus) { + return '-'; + } + + if (terminalStatus === 'cancelled') { + return '์ทจ์†Œ๋จ'; + } + + if (terminalStatus === 'removed') { + return '๋Œ€๊ธฐ์—ด ์ œ๊ฑฐ๋จ'; + } + + if (terminalStatus === 'failed') { + return '์‹คํŒจ'; + } + + return '์™„๋ฃŒ'; +} + +const AUTOMATION_NOTIFICATION_OPTIONS: Array<{ + key: keyof Pick< + AppConfig['automation'], + | 'notifyOnAutomationStart' + | 'notifyOnAutomationProgress' + | 'notifyOnAutomationCompletion' + | 'notifyOnAutomationRelease' + | 'notifyOnAutomationMain' + | 'notifyOnAutomationFailure' + | 'notifyOnAutomationRestart' + | 'notifyOnAutomationIssueResolved' + >; + label: string; + description: string; +}> = [ + { + key: 'notifyOnAutomationStart', + label: '์ž๋™ํ™” ์‹œ์ž‘', + description: '์ž‘์—… ์ž๋™ํ™”๊ฐ€ ์‹œ์ž‘๋  ๋•Œ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationProgress', + label: '์ž๋™ํ™” ์ง„ํ–‰', + description: '์ž๋™ ์ž‘์—…์ด ์˜ค๋ž˜ ๊ฑธ๋ฆด ๋•Œ ์ง„ํ–‰ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationCompletion', + label: '์ž๋™ ์ž‘์—… ์™„๋ฃŒ', + description: '์ž‘์—…์™„๋ฃŒ, ๋ณ€๊ฒฝ ์—†์Œ ์™„๋ฃŒ, ์ˆ˜๋™ ์™„๋ฃŒ ์ฒ˜๋ฆฌ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationRelease', + label: 'release ๋ฐ˜์˜ ์™„๋ฃŒ', + description: 'release ๋ฐ˜์˜ ์™„๋ฃŒ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationMain', + label: 'main ๋ฐ˜์˜ ์™„๋ฃŒ', + description: 'main ๋ฐ˜์˜๊ณผ ๋ฉ”์ธ ํ”„๋กœ์ ํŠธ ๋™๊ธฐํ™” ์™„๋ฃŒ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationFailure', + label: '์ž๋™ํ™” ์‹คํŒจ', + description: '๋ธŒ๋žœ์น˜, ์ž๋™ ์ž‘์—…, release, main ๋ฐ˜์˜ ์‹คํŒจ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationRestart', + label: '์ž‘์—… ์žฌ์‹œ์ž‘', + description: '์žฌ์‹œ๋„๋‚˜ ์ด์Šˆ ์กฐ์น˜๋กœ ์ž‘์—…์ด ๋‹ค์‹œ ํ์— ๋“ค์–ด๊ฐˆ ๋•Œ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, + { + key: 'notifyOnAutomationIssueResolved', + label: '์ด์Šˆ ํ•ด๊ฒฐ ์ฒ˜๋ฆฌ', + description: '์ตœ์‹  ์ด์Šˆ๊ฐ€ ํ•ด๊ฒฐ ์ฒ˜๋ฆฌ๋  ๋•Œ ์•Œ๋ฆผ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.', + }, +]; + +function areAutomationNotificationSettingsEqual(left: AppConfig['automation'], right: AppConfig['automation']) { + return AUTOMATION_NOTIFICATION_OPTIONS.every((option) => left[option.key] === right[option.key]); +} + +function getAutomationNotificationDiffLabels(saved: AppConfig['automation'], draft: AppConfig['automation']) { + return AUTOMATION_NOTIFICATION_OPTIONS.filter((option) => saved[option.key] !== draft[option.key]).map( + (option) => option.label, + ); +} + +function areGestureShortcutSettingsEqual( + left: AppConfig['gestureShortcuts'], + right: AppConfig['gestureShortcuts'], +) { + return left.openSearch === right.openSearch && left.openWindowSearch === right.openWindowSearch; +} + +function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draft: AppConfig['gestureShortcuts']) { + const changedLabels: string[] = []; + + if (saved.openSearch !== draft.openSearch) { + changedLabels.push('ํ†ตํ•ฉ ๊ฒ€์ƒ‰ ์—ด๊ธฐ'); + } + + if (saved.openWindowSearch !== draft.openWindowSearch) { + changedLabels.push('Window UI ๊ฒ€์ƒ‰ ์—ด๊ธฐ'); + } + + return changedLabels; +} + +function isSettingsModalAllowed(modal: SettingsModalKey, hasAccess: boolean) { + if (hasAccess) { + return true; + } + + return modal === 'token'; +} + +function getSettingsModalTitle(modal: SettingsModalKey) { + switch (modal) { + case 'notification': + return '์•Œ๋ฆผ'; + case 'token': + return '๊ถŒํ•œ'; + case 'update': + return '์—…๋ฐ์ดํŠธ'; + default: + return '์•ฑ ์„ค์ •'; + } +} + +function getAppUpdateStatusLabel(status: AppUpdateStatus) { + switch (status) { + case 'available': + return '์—…๋ฐ์ดํŠธ ๊ฐ€๋Šฅ'; + case 'updating': + return '์—…๋ฐ์ดํŠธ ์ค‘'; + case 'ready': + return '์ตœ์‹  ์ƒํƒœ'; + default: + return 'ํ™•์ธ ์ค‘'; + } +} + +function formatDateTimeLabel(value: string | null) { + if (!value) { + return '-'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return value; + } + + return new Intl.DateTimeFormat('ko-KR', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(parsed); +} + +function getWorkServerUpdateStatusLabel(item: ServerCommandItem | null) { + if (!item) { + return 'ํ™•์ธ ์ „'; + } + + if (item.buildRequired) { + return '์†Œ์Šค ๋ณ€๊ฒฝ ๊ฐ์ง€๋จ'; + } + + if (item.updateAvailable) { + return '์ƒˆ ๋นŒ๋“œ ๋Œ€๊ธฐ ์ค‘'; + } + + if (item.availability === 'online') { + return '์ตœ์‹  ๋นŒ๋“œ ์‹คํ–‰ ์ค‘'; + } + + return '์ƒํƒœ ํ™•์ธ ํ•„์š”'; +} + +function getClientNotificationPermission(): ClientNotificationPermissionState { + if ( + typeof window === 'undefined' || + typeof Notification === 'undefined' || + typeof navigator === 'undefined' || + !('serviceWorker' in navigator) || + !('PushManager' in window) + ) { + return 'unsupported'; + } + + if (Notification.permission === 'granted') { + return 'granted'; + } + + if (Notification.permission === 'denied') { + return 'denied'; + } + + return 'default'; +} + +function getClientNotificationPermissionLabel(permission: ClientNotificationPermissionState) { + switch (permission) { + case 'granted': + return 'ํ—ˆ์šฉ๋จ'; + case 'denied': + return '์ฐจ๋‹จ๋จ'; + case 'unsupported': + return '๋ฏธ์ง€์›'; + default: + return '๋ฏธํ™•์ธ'; + } +} + +function isStandaloneDisplayMode() { + if (typeof window === 'undefined') { + return false; + } + + return ( + window.matchMedia?.('(display-mode: standalone)').matches === true || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true + ); +} + +function isAppleMobileDevice() { + if (typeof navigator === 'undefined') { + return false; + } + + return /iPhone|iPad|iPod/i.test(navigator.userAgent); +} + +function hasSecureOrigin() { + if (typeof window === 'undefined') { + return false; + } + + return window.isSecureContext || window.location.hostname === 'localhost'; +} + +function urlBase64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let index = 0; index < rawData.length; index += 1) { + outputArray[index] = rawData.charCodeAt(index); + } + + return outputArray; +} + +function isSamePushApplicationServerKey( + leftKey: ArrayBuffer | null | undefined, + rightKey: Uint8Array, +) { + if (!leftKey) { + return false; + } + + const leftBytes = new Uint8Array(leftKey); + + if (leftBytes.byteLength !== rightKey.byteLength) { + return false; + } + + for (let index = 0; index < leftBytes.byteLength; index += 1) { + if (leftBytes[index] !== rightKey[index]) { + return false; + } + } + + return true; +} + +function serializePushSubscription(subscription: PushSubscription): WebPushSubscriptionPayload { + const json = subscription.toJSON(); + + return { + endpoint: subscription.endpoint, + expirationTime: subscription.expirationTime, + keys: { + p256dh: json.keys?.p256dh ?? '', + auth: json.keys?.auth ?? '', + }, + }; +} + +async function getPushServiceWorkerRegistration() { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return null; + } + + const serviceWorkerUrl = import.meta.env.DEV ? '/dev-sw.js?dev-sw' : '/sw.js'; + const resolvedServiceWorkerUrl = + typeof window !== 'undefined' ? new URL(serviceWorkerUrl, window.location.href).toString() : serviceWorkerUrl; + const timeoutMs = 30000; + const startedAt = Date.now(); + let registeredRegistration: ServiceWorkerRegistration | null = null; + + const waitForServiceWorkerReady = async (remainingTimeoutMs: number) => { + const readyStartedAt = Date.now(); + + while (Date.now() - readyStartedAt < remainingTimeoutMs) { + try { + const readyRegistration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((resolve) => { + window.setTimeout(() => resolve(null), 1000); + }), + ]); + + if (readyRegistration) { + return readyRegistration; + } + } catch { + // keep polling until timeout + } + + await new Promise((resolve) => { + window.setTimeout(resolve, 300); + }); + } + + return null; + }; + + const resolveUsableRegistration = async (registration: ServiceWorkerRegistration | null | undefined) => { + if (!registration) { + return null; + } + + if (registration.active || registration.waiting) { + return registration; + } + + const installingWorker = registration.installing; + + if (!installingWorker) { + return registration; + } + + const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); + + await new Promise((resolve) => { + let completed = false; + + const finish = () => { + if (completed) { + return; + } + + completed = true; + window.clearTimeout(timeoutId); + installingWorker.removeEventListener('statechange', onStateChange); + resolve(); + }; + + const onStateChange = () => { + if ( + installingWorker.state === 'activated' || + installingWorker.state === 'installed' || + Boolean(registration.active) || + Boolean(registration.waiting) + ) { + finish(); + } + }; + + const timeoutId = window.setTimeout(finish, remainingTimeoutMs); + installingWorker.addEventListener('statechange', onStateChange); + onStateChange(); + }); + + if (registration.active || registration.waiting) { + return registration; + } + + const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); + return readyRegistration ?? registration; + }; + + const tryRegister = async (url: string, mode: string, options?: RegistrationOptions) => { + try { + registeredRegistration = await navigator.serviceWorker.register(url, options); + return true; + } catch (error) { + lastPushSwRegisterAttempts = [ + ...lastPushSwRegisterAttempts, + { + url, + mode, + error: error instanceof Error ? error.message : 'unknown error', + }, + ]; + return false; + } + }; + + const devOptionsModule = { type: 'module', scope: '/', updateViaCache: 'none' } as const; + const devOptionsClassic = { scope: '/', updateViaCache: 'none' } as const; + + if (import.meta.env.DEV) { + lastPushSwRegisterAttempts = []; + const registered = + (await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule)) || + (await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic)); + + if (!registered) { + // registerSW helper in main.tsx may already be handling this path; if manual registration fails, + // continue and try to resolve an existing active registration below. + } + } else { + await tryRegister(resolvedServiceWorkerUrl, 'default'); + } + + const usableRegisteredRegistration = await resolveUsableRegistration(registeredRegistration); + + if (usableRegisteredRegistration) { + return usableRegisteredRegistration; + } + + while (Date.now() - startedAt < timeoutMs) { + const existingRegistration = await navigator.serviceWorker.getRegistration(); + const usableExistingRegistration = await resolveUsableRegistration(existingRegistration); + + if (usableExistingRegistration) { + return usableExistingRegistration; + } + + const scopedRegistration = await navigator.serviceWorker.getRegistration('/'); + const usableScopedRegistration = await resolveUsableRegistration(scopedRegistration); + + if (usableScopedRegistration) { + return usableScopedRegistration; + } + + const registrations = await navigator.serviceWorker.getRegistrations(); + + for (const registration of registrations) { + const usableRegistration = await resolveUsableRegistration(registration); + + if (usableRegistration) { + return usableRegistration; + } + } + + try { + const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); + const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); + const usableReadyRegistration = await resolveUsableRegistration(readyRegistration); + + if (usableReadyRegistration) { + return usableReadyRegistration; + } + } catch { + // keep polling until timeout + } + + await new Promise((resolve) => { + window.setTimeout(resolve, 250); + }); + } + + const registrations = await navigator.serviceWorker.getRegistrations(); + + for (const registration of registrations) { + const usableRegistration = await resolveUsableRegistration(registration); + + if (usableRegistration) { + return usableRegistration; + } + } + + if (import.meta.env.DEV) { + try { + await Promise.all(registrations.map((registration) => registration.unregister())); + await waitForDuration(300); + + await tryRegister(resolvedServiceWorkerUrl, 'module', devOptionsModule); + if (!registeredRegistration) { + await tryRegister(resolvedServiceWorkerUrl, 'classic', devOptionsClassic); + } + + const remainingTimeoutMs = Math.max(1500, timeoutMs - (Date.now() - startedAt)); + const readyRegistration = await waitForServiceWorkerReady(remainingTimeoutMs); + const usableReadyRegistration = await resolveUsableRegistration(readyRegistration ?? registeredRegistration); + + if (usableReadyRegistration) { + return usableReadyRegistration; + } + } catch { + // ignore and fallthrough to null + } + } + + return null; +} + +function waitForDuration(durationMs: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, durationMs); + }); +} + +async function copyText(text: string) { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + if (typeof document === 'undefined') { + throw new Error('ํด๋ฆฝ๋ณด๋“œ API๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'); + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); +} + +export function MainHeader({ + activeTopMenu, + sidebarCollapsed, + contentExpanded, + isMobileViewport, + onToggleSidebar, + onToggleContentExpanded, + onChangeTopMenu, + onOpenPlanQuickFilter, +}: MainHeaderProps) { + void contentExpanded; + void onToggleContentExpanded; + const screens = useBreakpoint(); + const navigate = useNavigate(); + const location = useLocation(); + const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); + const [activeSettingsModal, setActiveSettingsModal] = useState('appSettings'); + const [activeAppSettingsCategory, setActiveAppSettingsCategory] = useState('automation'); + const [activeAppSettingsSection, setActiveAppSettingsSection] = useState('planDefaults'); + const [notificationEnabled, setNotificationEnabled] = useState(false); + const [notificationLoading, setNotificationLoading] = useState(false); + const [notificationFeedback, setNotificationFeedback] = useState(null); + const [notificationCopyFeedback, setNotificationCopyFeedback] = useState(null); + const [registeredPwaNotificationToken, setRegisteredPwaNotificationToken] = useState(() => + getSavedPwaNotificationToken(), + ); + const [pwaNotificationTokenInput, setPwaNotificationTokenInput] = useState(() => getSavedPwaNotificationToken()); + const [pwaNotificationTokenSaving, setPwaNotificationTokenSaving] = useState(false); + const [pwaNotificationTokenFeedback, setPwaNotificationTokenFeedback] = useState(null); + const [pwaNotificationTokenCopyFeedback, setPwaNotificationTokenCopyFeedback] = useState(null); + const [clientNotificationPermission, setClientNotificationPermission] = useState( + () => getClientNotificationPermission(), + ); + const [webPushConfigured, setWebPushConfigured] = useState(false); + const [isStandaloneMode, setIsStandaloneMode] = useState(false); + const [appUpdateStatus, setAppUpdateStatus] = useState(() => getAppUpdateSnapshot().status); + const [appUpdateSupported, setAppUpdateSupported] = useState(() => getAppUpdateSnapshot().supported); + const [appUpdateProgressPercent, setAppUpdateProgressPercent] = useState( + () => getAppUpdateSnapshot().progressPercent, + ); + const [appUpdateCurrentTaskLabel, setAppUpdateCurrentTaskLabel] = useState( + () => getAppUpdateSnapshot().currentTaskLabel, + ); + const [chatConnection, setChatConnection] = useState(() => chatConnectionGateway.getSnapshot()); + const [chatRuntimeSnapshot, setChatRuntimeSnapshot] = useState(() => + chatConnectionGateway.getSharedRuntimeSnapshot(), + ); + const [activeConversationSnapshot, setActiveConversationSnapshot] = useState(() => + getSharedActiveConversationSnapshot(), + ); + const [isRuntimeModalOpen, setIsRuntimeModalOpen] = useState(false); + const [runtimeConversationTitles, setRuntimeConversationTitles] = useState>({}); + const [isRuntimeLogDrawerOpen, setIsRuntimeLogDrawerOpen] = useState(false); + const [runtimeLogLoading, setRuntimeLogLoading] = useState(false); + const [runtimeLogError, setRuntimeLogError] = useState(''); + const [runtimeLogDetail, setRuntimeLogDetail] = useState(null); + const [appUpdateFeedback, setAppUpdateFeedback] = useState(null); + const [appUpdateCopyFeedback, setAppUpdateCopyFeedback] = useState(null); + const previousAppUpdateStatusRef = useRef(getAppUpdateSnapshot().status); + const [workServerStatus, setWorkServerStatus] = useState(null); + const [workServerStatusLoading, setWorkServerStatusLoading] = useState(false); + const [workServerRestarting, setWorkServerRestarting] = useState(false); + const [workServerUpdateFeedback, setWorkServerUpdateFeedback] = useState(null); + const [workServerUpdateCopyFeedback, setWorkServerUpdateCopyFeedback] = useState(null); + const { registeredToken, hasAccess } = useTokenAccess(); + const appConfig = useAppConfig(); + const [appConfigDraft, setAppConfigDraft] = useState(appConfig); + const [appConfigFeedback, setAppConfigFeedback] = useState(null); + const [appConfigSaving, setAppConfigSaving] = useState(false); + const [appConfigCopyFeedback, setAppConfigCopyFeedback] = useState(null); + const [tokenInput, setTokenInput] = useState(''); + const [tokenFeedback, setTokenFeedback] = useState(null); + const [planShortcutCounts, setPlanShortcutCounts] = useState({ + working: 0, + releasePendingMain: 0, + automationFailed: 0, + }); + const headerTopMenu = !hasAccess ? 'docs' : activeTopMenu === 'apis' ? 'docs' : activeTopMenu === 'chat' ? 'plans' : activeTopMenu; + const notificationStatusClassName = notificationEnabled + ? 'app-header__status-dot--active' + : 'app-header__status-dot--inactive'; + const appUpdateStatusClassName = + appUpdateStatus === 'available' + ? 'app-header__status-dot--warning' + : appUpdateStatus === 'updating' + ? 'app-header__status-dot--progress' + : 'app-header__status-dot--active'; + const chatConnectionStatusClassName = + chatConnection.connectionState === 'connected' + ? 'app-header__status-dot--active' + : chatConnection.connectionState === 'connecting' + ? 'app-header__status-dot--progress' + : 'app-header__status-dot--inactive'; + const appPendingUpdateCount = appUpdateStatus === 'available' ? 1 : 0; + const workServerPendingUpdateCount = + workServerStatus && (workServerStatus.updateAvailable || workServerStatus.buildRequired) ? 1 : 0; + const totalPendingUpdateCount = appPendingUpdateCount + workServerPendingUpdateCount; + const settingsStatusClassName = + totalPendingUpdateCount >= 2 + ? 'app-header__status-dot--inactive' + : totalPendingUpdateCount === 1 + ? 'app-header__status-dot--warning' + : 'app-header__status-dot--active'; + const settingsStatusLabel = + totalPendingUpdateCount >= 2 ? '๋ชจ๋“  ์—…๋ฐ์ดํŠธ ์กด์žฌ' : totalPendingUpdateCount === 1 ? '์—…๋ฐ์ดํŠธ 1๊ฑด ์กด์žฌ' : '์ตœ์‹  ์ƒํƒœ'; + const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0; + const queuedRuntimeCount = chatRuntimeSnapshot?.queuedCount ?? 0; + const runtimeSessionCount = chatRuntimeSnapshot?.sessionCount ?? 0; + const runningRuntimeBadgeLabel = runningRuntimeCount > 99 ? '99+' : String(runningRuntimeCount); + const hasPendingRuntimeWork = runningRuntimeCount > 0 || queuedRuntimeCount > 0; + const chatConnectionLabelParts = [ + chatConnection.connectionState === 'connected' + ? 'Codex ์ฑ„ํŒ… ์‹ค์‹œ๊ฐ„ ์—ฐ๊ฒฐ๋จ' + : chatConnection.connectionState === 'connecting' + ? 'Codex ์ฑ„ํŒ… ์—ฐ๊ฒฐ ์ค‘' + : 'Codex ์ฑ„ํŒ… ์˜คํ”„๋ผ์ธ', + ]; + + if (runningRuntimeCount > 0) { + chatConnectionLabelParts.push(`์‹คํ–‰ ${runningRuntimeCount}๊ฑด`); + } + + if (queuedRuntimeCount > 0) { + chatConnectionLabelParts.push(`๋Œ€๊ธฐ ${queuedRuntimeCount}๊ฑด`); + } + + const chatConnectionLabel = chatConnectionLabelParts.join(' ยท '); + const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${ + hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : '' + }`; + const connectionCountBadgeClassName = `app-header__connection-count-badge app-header__connection-count-badge--${chatConnection.connectionState}`; + const getConversationLabel = (sessionId: string) => { + if (sessionId === activeConversationSnapshot.sessionId && activeConversationSnapshot.title) { + return activeConversationSnapshot.title; + } + + return runtimeConversationTitles[sessionId] || sessionId; + }; + const navigateToConversation = (sessionId: string) => { + const searchParams = new URLSearchParams(location.search); + searchParams.set('topMenu', 'chat'); + searchParams.set('sessionId', sessionId); + navigate({ + pathname: buildChatPath('live'), + search: `?${searchParams.toString()}`, + }); + setIsRuntimeModalOpen(false); + }; + const openRuntimeLog = async (requestId: string) => { + setIsRuntimeModalOpen(false); + setIsRuntimeLogDrawerOpen(true); + setRuntimeLogLoading(true); + setRuntimeLogError(''); + + try { + const detail = await fetchChatRuntimeJobDetail(requestId); + setRuntimeLogDetail(detail); + } catch (error) { + setRuntimeLogDetail(null); + setRuntimeLogError(error instanceof Error ? error.message : '์‹คํ–‰ ๋กœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setRuntimeLogLoading(false); + } + }; + const canApplyAppUpdate = appUpdateSupported && appUpdateStatus === 'available'; + const canRefreshWorkServerStatus = hasAccess && !workServerRestarting && !workServerStatusLoading; + const canApplyWorkServerUpdate = + hasAccess && + !workServerRestarting && + !workServerStatusLoading; + const chatSettingsDirty = !areChatSettingsEqual(appConfig.chat, appConfigDraft.chat); + const worklogAutomationSettingsDirty = !areWorklogAutomationSettingsEqual( + appConfig.worklogAutomation, + appConfigDraft.worklogAutomation, + ); + const chatSettingsDiffLabels = getChatSettingsDiffLabels(appConfig.chat, appConfigDraft.chat); + const planDefaultSettingsDirty = !arePlanDefaultSettingsEqual(appConfig.planDefaults, appConfigDraft.planDefaults); + const planDefaultDiffLabels = getPlanDefaultDiffLabels(appConfig.planDefaults, appConfigDraft.planDefaults); + const planCostSettingsDirty = !arePlanCostSettingsEqual(appConfig.planCost, appConfigDraft.planCost); + const planCostDiffLabels = getPlanCostDiffLabels(appConfig.planCost, appConfigDraft.planCost); + const worklogAutomationDiffLabels = getWorklogAutomationDiffLabels( + appConfig.worklogAutomation, + appConfigDraft.worklogAutomation, + ); + const automationNotificationSettingsDirty = !areAutomationNotificationSettingsEqual( + appConfig.automation, + appConfigDraft.automation, + ); + const automationNotificationDiffLabels = getAutomationNotificationDiffLabels( + appConfig.automation, + appConfigDraft.automation, + ); + const gestureShortcutSettingsDirty = !areGestureShortcutSettingsEqual( + appConfig.gestureShortcuts, + appConfigDraft.gestureShortcuts, + ); + const gestureShortcutDiffLabels = getGestureShortcutDiffLabels( + appConfig.gestureShortcuts, + appConfigDraft.gestureShortcuts, + ); + const activeAppSettingsSectionOptions = getAppSettingsSectionOptions(activeAppSettingsCategory); + const syncRegisteredWebPushStatus = async () => { + const permission = getClientNotificationPermission(); + setClientNotificationPermission(permission); + setIsStandaloneMode(isStandaloneDisplayMode()); + + if (permission === 'unsupported') { + setNotificationEnabled(false); + return; + } + + try { + const config = await fetchWebPushConfig(); + setWebPushConfigured(Boolean(config.enabled && config.publicKey)); + + if (!config.enabled || !config.publicKey) { + setNotificationEnabled(false); + return; + } + + if (Notification.permission !== 'granted') { + setNotificationEnabled(false); + return; + } + + const registration = await getPushServiceWorkerRegistration(); + + if (!registration) { + setNotificationEnabled(false); + return; + } + + let subscription = await registration.pushManager.getSubscription(); + + if (!subscription) { + setNotificationEnabled(false); + return; + } + + const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); + + if (!isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey)) { + await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined); + await subscription.unsubscribe().catch(() => undefined); + + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: expectedApplicationServerKey, + }); + } + + await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId()); + setNotificationEnabled(true); + setNotificationFeedback(null); + } catch { + setNotificationEnabled(false); + } + }; + + useEffect(() => { + void syncRegisteredWebPushStatus(); + }, []); + + useEffect(() => { + const handleSync = () => { + void syncRegisteredWebPushStatus(); + }; + + window.addEventListener('focus', handleSync); + window.addEventListener('pageshow', handleSync); + document.addEventListener('visibilitychange', handleSync); + if ('serviceWorker' in navigator) { + void navigator.serviceWorker.ready + .then(() => { + void syncRegisteredWebPushStatus(); + }) + .catch(() => undefined); + } + + return () => { + window.removeEventListener('focus', handleSync); + window.removeEventListener('pageshow', handleSync); + document.removeEventListener('visibilitychange', handleSync); + }; + }, []); + + useEffect(() => { + return chatConnectionGateway.subscribe(() => { + setChatConnection(chatConnectionGateway.getSnapshot()); + setChatRuntimeSnapshot(chatConnectionGateway.getSharedRuntimeSnapshot()); + }); + }, []); + + useEffect(() => { + return subscribeSharedActiveConversation(() => { + setActiveConversationSnapshot(getSharedActiveConversationSnapshot()); + }); + }, []); + + useEffect(() => { + if (!isRuntimeModalOpen) { + return; + } + + let cancelled = false; + void chatGateway.listConversations() + .then((items) => { + if (cancelled) { + return; + } + + setRuntimeConversationTitles( + items.reduce>((acc, item) => { + acc[item.sessionId] = item.title?.trim() || item.sessionId; + return acc; + }, {}), + ); + }) + .catch(() => { + if (!cancelled) { + setRuntimeConversationTitles({}); + } + }); + + return () => { + cancelled = true; + }; + }, [isRuntimeModalOpen]); + + useEffect(() => { + return subscribeAppUpdate((nextSnapshot) => { + setAppUpdateSupported(nextSnapshot.supported); + setAppUpdateStatus(nextSnapshot.status); + setAppUpdateProgressPercent(nextSnapshot.progressPercent); + setAppUpdateCurrentTaskLabel(nextSnapshot.currentTaskLabel); + }); + }, []); + + useEffect(() => { + const previousStatus = previousAppUpdateStatusRef.current; + + if (appUpdateStatus === 'available' && previousStatus !== 'available') { + setAppUpdateFeedback({ + tone: 'info', + message: import.meta.env.DEV + ? '๊ฐœ๋ฐœ ์„œ๋ฒ„ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์ค€๋น„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • > ์—…๋ฐ์ดํŠธ์—์„œ ์•ฑ ์—…๋ฐ์ดํŠธ ์ ์šฉ์„ ๋ˆ„๋ฅด๋ฉด ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.' + : '์ƒˆ ์•ฑ ๋ฒ„์ „์ด ์ค€๋น„๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • > ์—…๋ฐ์ดํŠธ์—์„œ ์•ฑ ์—…๋ฐ์ดํŠธ ์ ์šฉ์„ ๋ˆ„๋ฅด์„ธ์š”.', + }); + } else if (appUpdateStatus !== 'available' && previousStatus === 'available') { + setAppUpdateFeedback(null); + } + + previousAppUpdateStatusRef.current = appUpdateStatus; + }, [appUpdateStatus]); + + useEffect(() => { + setTokenInput(registeredToken); + }, [registeredToken]); + + useEffect(() => { + setAppConfigDraft(appConfig); + }, [appConfig]); + + useEffect(() => { + if (getAppSettingsSectionCategory(activeAppSettingsSection) === activeAppSettingsCategory) { + return; + } + + const firstSection = APP_SETTINGS_SECTIONS.find((entry) => entry.category === activeAppSettingsCategory); + + if (firstSection) { + setActiveAppSettingsSection(firstSection.value); + } + }, [activeAppSettingsCategory, activeAppSettingsSection]); + + useEffect(() => { + if (!isSettingsModalAllowed(activeSettingsModal, hasAccess)) { + setActiveSettingsModal('token'); + } + }, [activeSettingsModal, hasAccess]); + + useEffect(() => { + if (!hasAccess) { + setWorkServerStatus(null); + return; + } + + void refreshWorkServerStatus(true); + }, [hasAccess]); + + useEffect(() => { + if (!settingsModalOpen || activeSettingsModal !== 'update' || !hasAccess) { + return; + } + + void refreshWorkServerStatus(true); + }, [activeSettingsModal, hasAccess, settingsModalOpen]); + + const ensureClientNotificationPermission = async () => { + const currentPermission = getClientNotificationPermission(); + setClientNotificationPermission(currentPermission); + + if (currentPermission === 'unsupported') { + setNotificationFeedback({ tone: 'error', message: 'ํ˜„์žฌ ๋ธŒ๋ผ์šฐ์ €์—์„œ๋Š” Web Push๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.' }); + return false; + } + + if (!hasSecureOrigin()) { + setNotificationFeedback({ tone: 'error', message: '์•Œ๋ฆผ์€ HTTPS ๋˜๋Š” localhost ํ™˜๊ฒฝ์—์„œ๋งŒ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.' }); + return false; + } + + if (isAppleMobileDevice() && !isStandaloneDisplayMode()) { + setNotificationFeedback({ + tone: 'warning', + message: '์•„์ดํฐ์—์„œ๋Š” ํ™ˆ ํ™”๋ฉด์— ์ถ”๊ฐ€ํ•œ ๋’ค ์‹คํ–‰ํ•œ PWA์—์„œ๋งŒ ์›น ํ‘ธ์‹œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', + }); + return false; + } + + if (currentPermission === 'denied') { + setNotificationFeedback({ + tone: 'warning', + message: '๋ธŒ๋ผ์šฐ์ € ๋˜๋Š” ๊ธฐ๊ธฐ ์„ค์ •์—์„œ ์•Œ๋ฆผ ๊ถŒํ•œ์„ ๋จผ์ € ํ—ˆ์šฉํ•ด ์ฃผ์„ธ์š”.', + }); + return false; + } + + if (currentPermission === 'granted') { + setNotificationFeedback(null); + return true; + } + + try { + const permission = await Notification.requestPermission(); + const nextPermission = + permission === 'granted' ? 'granted' : permission === 'denied' ? 'denied' : 'default'; + setClientNotificationPermission(nextPermission); + + if (nextPermission !== 'granted') { + setNotificationFeedback({ tone: 'warning', message: '์•Œ๋ฆผ ๊ถŒํ•œ์ด ํ—ˆ์šฉ๋˜์ง€ ์•Š์•„ ์•Œ๋ฆผ์„ ์ผค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' }); + return false; + } + + setNotificationFeedback(null); + return true; + } catch { + setNotificationFeedback({ tone: 'error', message: '์•Œ๋ฆผ ๊ถŒํ•œ ์š”์ฒญ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + return false; + } + }; + + const syncNotificationEnabled = async (nextEnabled: boolean) => { + setNotificationCopyFeedback(null); + + if (nextEnabled) { + const permissionGranted = await ensureClientNotificationPermission(); + + if (!permissionGranted) { + setNotificationEnabled(false); + return; + } + } + + setNotificationLoading(true); + + try { + const config = await fetchWebPushConfig(); + setWebPushConfigured(Boolean(config.enabled && config.publicKey)); + + if (!config.enabled || !config.publicKey) { + throw new Error('์„œ๋ฒ„ Web Push ์„ค์ •์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค. VAPID ์„ค์ •์„ ๋จผ์ € ํ™•์ธํ•ด ์ฃผ์„ธ์š”.'); + } + + let registration = await getPushServiceWorkerRegistration(); + + if (!registration) { + await waitForDuration(1500); + registration = await getPushServiceWorkerRegistration(); + } + + if (!registration) { + if (nextEnabled) { + if (import.meta.env.DEV) { + const serviceWorkerUrl = '/dev-sw.js?dev-sw'; + let swFetchStatus = 'unknown'; + + try { + const response = await fetch(serviceWorkerUrl, { cache: 'no-store' }); + swFetchStatus = `${response.status} ${response.statusText}`; + } catch { + swFetchStatus = 'fetch failed'; + } + + let registrationSummary = 'none'; + + try { + const registrations = await navigator.serviceWorker.getRegistrations(); + registrationSummary = + registrations.length === 0 + ? 'none' + : registrations + .map((item) => { + const states = [ + item.active ? `active:${item.active.state}` : 'active:none', + item.waiting ? `waiting:${item.waiting.state}` : 'waiting:none', + item.installing ? `installing:${item.installing.state}` : 'installing:none', + ]; + return `${item.scope} [${states.join(', ')}]`; + }) + .join(' | '); + } catch { + registrationSummary = 'read failed'; + } + + throw new Error( + [ + '์•Œ๋ฆผ ์„œ๋น„์Šค์›Œ์ปค ์ค€๋น„๊ฐ€ ์•„์ง ๋๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.', + `dev-sw ์‘๋‹ต: ${swFetchStatus}`, + `๋“ฑ๋ก ์ƒํƒœ: ${registrationSummary}`, + `๋“ฑ๋ก ์‹œ๋„: ${ + lastPushSwRegisterAttempts.length === 0 + ? 'none' + : lastPushSwRegisterAttempts + .map((attempt) => `${attempt.mode} ${attempt.url} -> ${attempt.error}`) + .join(' | ') + }`, + `secureContext: ${window.isSecureContext ? 'yes' : 'no'}`, + `permission: ${Notification.permission}`, + ].join(' '), + ); + } + + throw new Error('์•Œ๋ฆผ ์„œ๋น„์Šค์›Œ์ปค ์ค€๋น„๊ฐ€ ์•„์ง ๋๋‚˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.'); + } + + setNotificationEnabled(false); + setNotificationFeedback({ tone: 'success', message: '์ด ๊ธฐ๊ธฐ์˜ ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ํ•ด์ œํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + return; + } + + if (nextEnabled) { + let subscription = await registration.pushManager.getSubscription(); + const expectedApplicationServerKey = urlBase64ToUint8Array(config.publicKey); + + if ( + subscription && + !isSamePushApplicationServerKey(subscription.options.applicationServerKey, expectedApplicationServerKey) + ) { + await unregisterWebPushSubscription(subscription.endpoint).catch(() => undefined); + await subscription.unsubscribe().catch(() => undefined); + subscription = null; + } + + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: expectedApplicationServerKey, + }); + } + + await registerWebPushSubscription(serializePushSubscription(subscription), getSavedNotificationDeviceId()); + setNotificationEnabled(true); + setNotificationFeedback({ tone: 'success', message: '์ด ๊ธฐ๊ธฐ์˜ ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ์„œ๋ฒ„์— ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + return; + } + + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await unregisterWebPushSubscription(subscription.endpoint); + await subscription.unsubscribe(); + } + + setNotificationEnabled(false); + setNotificationFeedback({ tone: 'success', message: '์ด ๊ธฐ๊ธฐ์˜ ์›น ํ‘ธ์‹œ ์•Œ๋ฆผ์„ ํ•ด์ œํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + } catch (error) { + setNotificationEnabled(!nextEnabled); + setNotificationFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '์•Œ๋ฆผ ์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } finally { + setNotificationLoading(false); + } + }; + + const handleRegisterPwaNotificationToken = async () => { + const trimmedToken = pwaNotificationTokenInput.trim(); + setPwaNotificationTokenCopyFeedback(null); + + if (!trimmedToken) { + setPwaNotificationTokenFeedback({ tone: 'warning', message: '๋“ฑ๋กํ•  PWA ์•Œ๋ฆผ ํ† ํฐ์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.' }); + return; + } + + setPwaNotificationTokenSaving(true); + + try { + await registerPwaNotificationToken({ + token: trimmedToken, + deviceId: getSavedNotificationDeviceId(), + }); + + if (registeredPwaNotificationToken && registeredPwaNotificationToken !== trimmedToken) { + await unregisterPwaNotificationToken(registeredPwaNotificationToken); + } + + setSavedPwaNotificationToken(trimmedToken); + setRegisteredPwaNotificationToken(trimmedToken); + void syncAppConfigFromServer(); + setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA ์•Œ๋ฆผ ํ† ํฐ์„ ์„œ๋ฒ„์— ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + } catch (error) { + setPwaNotificationTokenFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : 'PWA ์•Œ๋ฆผ ํ† ํฐ ๋“ฑ๋ก์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } finally { + setPwaNotificationTokenSaving(false); + } + }; + + const handleClearPwaNotificationToken = async () => { + const tokenToRemove = registeredPwaNotificationToken || pwaNotificationTokenInput.trim(); + setPwaNotificationTokenCopyFeedback(null); + + if (!tokenToRemove) { + setPwaNotificationTokenFeedback({ tone: 'info', message: '์ œ๊ฑฐํ•  PWA ์•Œ๋ฆผ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค.' }); + return; + } + + setPwaNotificationTokenSaving(true); + + try { + await unregisterPwaNotificationToken(tokenToRemove); + setSavedPwaNotificationToken(''); + setRegisteredPwaNotificationToken(''); + setPwaNotificationTokenInput(''); + void syncAppConfigFromServer(); + setPwaNotificationTokenFeedback({ tone: 'success', message: 'PWA ์•Œ๋ฆผ ํ† ํฐ์„ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + } catch (error) { + setPwaNotificationTokenFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : 'PWA ์•Œ๋ฆผ ํ† ํฐ ์ œ๊ฑฐ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } finally { + setPwaNotificationTokenSaving(false); + } + }; + + const handleApplyAppUpdate = async () => { + setAppUpdateCopyFeedback(null); + + if (!appUpdateSupported) { + setAppUpdateFeedback({ tone: 'warning', message: 'ํ˜„์žฌ ํ™˜๊ฒฝ์—์„œ๋Š” ์•ฑ ์—…๋ฐ์ดํŠธ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.' }); + return; + } + + if (appUpdateStatus === 'updating') { + return; + } + + try { + const applied = await applyAppUpdate(); + + if (!applied) { + setAppUpdateFeedback({ tone: 'info', message: 'ํ˜„์žฌ ์ ์šฉํ•  ์•ฑ ์—…๋ฐ์ดํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.' }); + return; + } + + setAppUpdateFeedback({ tone: 'success', message: '์•ฑ ์—…๋ฐ์ดํŠธ๋ฅผ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.' }); + } catch (error) { + setAppUpdateFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '์•ฑ ์—…๋ฐ์ดํŠธ ์ ์šฉ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + }; + + const refreshWorkServerStatus = async (silent = false) => { + if (!hasAccess) { + setWorkServerStatus(null); + if (!silent) { + setWorkServerUpdateFeedback({ tone: 'warning', message: '์›Œํฌ์„œ๋ฒ„ ์—…๋ฐ์ดํŠธ ํ™•์ธ์€ ๊ถŒํ•œ ํ† ํฐ ๋“ฑ๋ก ํ›„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.' }); + } + return null; + } + + setWorkServerStatusLoading(true); + + try { + const items = await fetchServerCommands(); + const nextWorkServerStatus = items.find((item) => item.key === 'work-server') ?? null; + setWorkServerStatus(nextWorkServerStatus); + + if (!silent) { + setWorkServerUpdateFeedback(null); + } + + return nextWorkServerStatus; + } catch (error) { + if (!silent) { + setWorkServerUpdateFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '์›Œํฌ์„œ๋ฒ„ ์—…๋ฐ์ดํŠธ ์ƒํƒœ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } + return null; + } finally { + setWorkServerStatusLoading(false); + } + }; + + const waitForWorkServerRestart = async () => { + for (let attempt = 0; attempt < 12; attempt += 1) { + await waitForDuration(2500); + + try { + const nextStatus = await refreshWorkServerStatus(true); + + if (!nextStatus) { + continue; + } + + if (nextStatus.availability === 'online' && !nextStatus.updateAvailable && !nextStatus.buildRequired) { + setWorkServerUpdateFeedback({ + tone: 'success', + message: '์›Œํฌ์„œ๋ฒ„๊ฐ€ ์žฌ์‹œ์ž‘๋˜์—ˆ๊ณ  ์ตœ์‹  ๋นŒ๋“œ๊ฐ€ ์ ์šฉ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + }); + return; + } + + if (nextStatus.availability === 'online' && nextStatus.buildRequired) { + setWorkServerUpdateFeedback({ + tone: 'info', + message: + nextStatus.updateSummary ?? '์›Œํฌ์„œ๋ฒ„๋Š” ์žฌ์‹œ์ž‘๋˜์—ˆ์ง€๋งŒ ์ตœ์‹  ์†Œ์Šค ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ๋นŒ๋“œ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', + }); + return; + } + } catch { + // ์„œ๋ฒ„๊ฐ€ ์žฌ์‹œ์ž‘ ์ค‘์ด๋ฉด ์ผ์‹œ์ ์œผ๋กœ ์‹คํŒจํ•  ์ˆ˜ ์žˆ์–ด ๋‹ค์Œ ์ฃผ๊ธฐ๊นŒ์ง€ ๊ธฐ๋‹ค๋ฆฝ๋‹ˆ๋‹ค. + } + } + + setWorkServerUpdateFeedback({ + tone: 'info', + message: '์›Œํฌ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์š”์ฒญ์€ ์ ‘์ˆ˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ ํ›„ ์—…๋ฐ์ดํŠธ ์ƒํƒœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.', + }); + }; + + const handleApplyWorkServerUpdate = async () => { + setWorkServerUpdateCopyFeedback(null); + + if (!hasAccess) { + setWorkServerUpdateFeedback({ tone: 'warning', message: '์›Œํฌ์„œ๋ฒ„ ์—…๋ฐ์ดํŠธ ์ ์šฉ์€ ๊ถŒํ•œ ํ† ํฐ ๋“ฑ๋ก ํ›„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.' }); + return; + } + + if (workServerRestarting || workServerStatusLoading) { + return; + } + + let nextStatus = workServerStatus; + + if (!nextStatus || (!nextStatus.updateAvailable && !nextStatus.buildRequired)) { + nextStatus = await refreshWorkServerStatus(true); + setWorkServerStatus(nextStatus); + } + + if (!nextStatus) { + setWorkServerUpdateFeedback({ tone: 'warning', message: '์›Œํฌ์„œ๋ฒ„ ์ƒํƒœ๋ฅผ ๋จผ์ € ํ™•์ธํ•ด ์ฃผ์„ธ์š”.' }); + return; + } + + if (!nextStatus.updateAvailable && !nextStatus.buildRequired) { + setWorkServerUpdateFeedback({ tone: 'info', message: 'ํ˜„์žฌ ์ ์šฉํ•  Work ์„œ๋ฒ„ ์—…๋ฐ์ดํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.' }); + return; + } + + setWorkServerUpdateFeedback(null); + setWorkServerRestarting(true); + + try { + const result = await restartServerCommand('work-server'); + setWorkServerStatus(result.item); + setWorkServerUpdateFeedback({ + tone: 'success', + message: + result.restartState === 'accepted' + ? '์›Œํฌ์„œ๋ฒ„ ์žฌ์‹œ์ž‘ ์š”์ฒญ์„ ์ ‘์ˆ˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ตœ์‹  ๋นŒ๋“œ ์ ์šฉ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.' + : '์›Œํฌ์„œ๋ฒ„๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ตœ์‹  ๋นŒ๋“œ ์ ์šฉ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•˜๋Š” ์ค‘์ž…๋‹ˆ๋‹ค.', + }); + await waitForWorkServerRestart(); + } catch (error) { + setWorkServerUpdateFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '์›Œํฌ์„œ๋ฒ„ ์žฌ์‹œ์ž‘์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } finally { + setWorkServerRestarting(false); + } + }; + + const openSettingsModal = ( + modal: SettingsModalKey, + nextAppSettingsSection: AppSettingsSectionKey = 'planDefaults', + ) => { + setSettingsOpen(false); + setActiveSettingsModal(isSettingsModalAllowed(modal, hasAccess) ? modal : 'token'); + setActiveAppSettingsCategory(getAppSettingsSectionCategory(nextAppSettingsSection)); + setActiveAppSettingsSection(nextAppSettingsSection); + setAppConfigDraft(appConfig); + setAppConfigFeedback(null); + setSettingsModalOpen(true); + }; + + const updateAppConfigDraft = (updater: (current: AppConfig) => AppConfig) => { + setAppConfigDraft((current) => updater(current)); + }; + + const handleSaveAppConfig = async () => { + if (appConfigSaving) { + return; + } + + setAppConfigSaving(true); + setAppConfigFeedback(null); + setAppConfigCopyFeedback(null); + + try { + const savedConfig = + activeAppSettingsSection === 'automationNotifications' + ? await saveAutomationNotificationPreferenceToServer(appConfigDraft) + : await saveAppConfigToServer(appConfigDraft); + setStoredAppConfig(savedConfig); + setAppConfigFeedback({ + tone: 'success', + message: + activeAppSettingsSection === 'automationNotifications' + ? '์ž๋™ํ™” ์•Œ๋ฆผ ์„ค์ •์„ ์•Œ๋ฆผ ํ† ํฐ๊ณผ ํด๋ผ์ด์–ธํŠธ๋ณ„๋กœ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.' + : '์•ฑ ์„ค์ •์„ DB์— ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } catch (error) { + setAppConfigDraft(appConfig); + setAppConfigFeedback({ + tone: 'error', + message: error instanceof Error ? error.message : '์•ฑ ์„ค์ • ์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + } finally { + setAppConfigSaving(false); + } + }; + + const handleResetNotificationIdentity = () => { + Modal.confirm({ + title: '์•Œ๋ฆผ ํด๋ผ์ด์–ธํŠธ ์ดˆ๊ธฐํ™”', + content: 'ํ˜„์žฌ ํด๋ผ์ด์–ธํŠธ์˜ ์•Œ๋ฆผ ํ† ํฐ/ํด๋ผ์ด์–ธํŠธ ID๋ฅผ ์ดˆ๊ธฐํ™”ํ•ฉ๋‹ˆ๋‹ค. ๋‹ค์‹œ ์ ‘์†ํ•˜๋ฉด ์ƒˆ๋กœ ๋“ฑ๋ก๋ฉ๋‹ˆ๋‹ค.', + okText: '์ดˆ๊ธฐํ™”', + cancelText: '์ทจ์†Œ', + onOk: () => { + clearNotificationIdentity(); + window.location.reload(); + }, + }); + }; + + const handleResetAppConfig = () => { + setAppConfigDraft(DEFAULT_APP_CONFIG); + setAppConfigFeedback({ tone: 'info', message: '์ถ”์ฒœ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋˜๋Œ๋ ธ์Šต๋‹ˆ๋‹ค. ์ €์žฅ ํ›„ ๋ฐ˜์˜๋ฉ๋‹ˆ๋‹ค.' }); + setAppConfigCopyFeedback(null); + }; + + useEffect(() => { + void syncAppConfigFromServer().then((synced) => { + if (!synced) { + setAppConfigFeedback({ tone: 'warning', message: '์„œ๋ฒ„ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ•ด ๋กœ์ปฌ ์„ค์ •์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.' }); + } + }); + }, []); + + useEffect(() => { + if (!hasAccess || !settingsOpen) { + setPlanShortcutCounts({ + working: 0, + releasePendingMain: 0, + automationFailed: 0, + }); + return; + } + + let cancelled = false; + + void fetchPlanItems('all') + .then((items) => { + if (cancelled) { + return; + } + + setPlanShortcutCounts({ + working: items.filter(isWorkingPlanItem).length, + releasePendingMain: items.filter(isReleasePendingMainItem).length, + automationFailed: items.filter(isAutomationFailedItem).length, + }); + }) + .catch(() => { + if (!cancelled) { + setPlanShortcutCounts({ + working: 0, + releasePendingMain: 0, + automationFailed: 0, + }); + } + }); + + return () => { + cancelled = true; + }; + }, [hasAccess, settingsOpen]); + + const renderFeedback = ( + feedback: InlineFeedback | null, + copyFeedback: InlineFeedback | null, + setCopyFeedback: (feedback: InlineFeedback | null) => void, + ) => { + if (!feedback) { + return null; + } + + return ( + + } + onClick={() => { + void copyText(feedback.message) + .then(() => { + setCopyFeedback({ tone: 'success', message: '๋ฉ”์‹œ์ง€๋ฅผ ๋ณต์‚ฌํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + }) + .catch(() => { + setCopyFeedback({ tone: 'error', message: '๋ฉ”์‹œ์ง€ ๋ณต์‚ฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + }); + }} + /> + } + /> + {copyFeedback ? : null} + + ); + }; + + const handleRegisterToken = () => { + const trimmedToken = tokenInput.trim(); + + if (!trimmedToken) { + setTokenFeedback({ tone: 'warning', message: '๋“ฑ๋กํ•  ํ† ํฐ์„ ์ž…๋ ฅํ•ด ์ฃผ์„ธ์š”.' }); + return; + } + + if (trimmedToken !== ALLOWED_REGISTRATION_TOKEN) { + setTokenFeedback({ tone: 'error', message: 'ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.' }); + return; + } + + setRegisteredAccessToken(trimmedToken); + setTokenFeedback({ tone: 'success', message: '๊ถŒํ•œ ํ† ํฐ์„ ๋“ฑ๋กํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + }; + + const handleClearToken = () => { + setRegisteredAccessToken(''); + setTokenInput(''); + setTokenFeedback({ tone: 'info', message: '๊ถŒํ•œ ํ† ํฐ์„ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค.' }); + }; + + const settingsTriggerButton = ( + + + + + + ); + + const chatSettingsPanel = ( + + + +
+ ์ตœ๊ทผ ๋ฌธ๋งฅ ๋ฉ”์‹œ์ง€ ์ˆ˜ + ์ตœ๊ทผ ๋Œ€ํ™” ์ค‘ ๋ช‡ ๊ฐœ์˜ ๋ฉ”์‹œ์ง€๋ฅผ ์ฑ„ํŒ… ๋ฌธ๋งฅ์œผ๋กœ ์ฐธ์กฐํ• ์ง€ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. + { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + maxContextMessages: + typeof value === 'number' && Number.isFinite(value) + ? Math.min(50, Math.max(1, Math.round(value))) + : DEFAULT_APP_CONFIG.chat.maxContextMessages, + }, + })); + }} + /> +
+ +
+ ์ตœ๊ทผ ๋ฌธ๋งฅ ๊ธ€์ž ์ˆ˜ + ์ตœ๊ทผ ๋Œ€ํ™” ์ „์ฒด์—์„œ ์ฐธ์กฐํ•  ์ตœ๋Œ€ ๊ธ€์ž ์ˆ˜์ž…๋‹ˆ๋‹ค. ์ดˆ๊ณผํ•˜๋ฉด ์ „์†ก ์ „์— ํ™•์ธ ๋ชจ๋‹ฌ์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. + { + setAppConfigDraft((current) => ({ + ...current, + chat: { + ...current.chat, + maxContextChars: + typeof value === 'number' && Number.isFinite(value) + ? Math.min(20_000, Math.max(500, Math.round(value))) + : DEFAULT_APP_CONFIG.chat.maxContextChars, + }, + })); + }} + /> +
+
+ ); + + const planDefaultsPanel = ( + + + + {renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)} + { + updateAppConfigDraft((current) => ({ + ...current, + planDefaults: { + ...current.planDefaults, + jangsingProcessingRequired: event.target.checked, + }, + })); + }} + > + ๊ธฐ๋Šฅ๋™์ž‘ํ™•์ธ ๊ธฐ๋ณธ๊ฐ’์„ ์™„๋ฃŒ๋กœ ์„ค์ • + + { + updateAppConfigDraft((current) => ({ + ...current, + planDefaults: { + ...current.planDefaults, + autoDeployToMain: event.target.checked, + }, + })); + }} + > + ๋ฉ”์ธ๊นŒ์ง€ ์ž๋™๋“ฑ๋ก + + { + updateAppConfigDraft((current) => ({ + ...current, + planDefaults: { + ...current.planDefaults, + openEditorAfterCreate: event.target.checked, + }, + })); + }} + > + ์ƒˆ ํ•ญ๋ชฉ ๋“ฑ๋ก ํ›„ ํŽธ์ง‘๊ธฐ ์ž๋™ ์—ด๊ธฐ + + + + + + + ); + + const planCostPanel = ( + + + + {renderFeedback(appConfigFeedback, appConfigCopyFeedback, setAppConfigCopyFeedback)} + + ๋ฐฑ๋งŒ ํ† ํฐ๋‹น ๊ธฐ์ค€ ๋น„์šฉ + { + updateAppConfigDraft((current) => ({ + ...current, + planCost: { + ...current.planCost, + baseCostPerMillionTokens: typeof value === 'number' ? value : current.planCost.baseCostPerMillionTokens, + }, + })); + }} + /> + + ์˜ˆ: {formatCostAmount(appConfigDraft.planCost.baseCostPerMillionTokens)} ๊ธฐ์ค€์ด๋ฉด ๋ˆ„์  ํ† ํฐ 100๋งŒ๊ฐœ์—์„œ ์žฌ์ฒ˜๋ฆฌ + ๊ฐ€์‚ฐ ์ „ ๊ธฐ๋ณธ ๋น„์šฉ์ด ๋™์ผํ•˜๊ฒŒ ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค. + + + + ์žฌ์ฒ˜๋ฆฌ ๊ฐ€์‚ฐ ๋น„์œจ + { + updateAppConfigDraft((current) => ({ + ...current, + planCost: { + ...current.planCost, + retryCostMultiplierPercent: + typeof value === 'number' ? value : current.planCost.retryCostMultiplierPercent, + }, + })); + }} + /> + ์žฌ์ฒ˜๋ฆฌ 1ํšŒ๋งˆ๋‹ค ๊ธฐ๋ณธ ๋น„์šฉ์— ๊ฐ€์‚ฐํ•  ๋น„์œจ์ž…๋‹ˆ๋‹ค. + + + ์‹œ๊ฐ„ ๊ฐ€์‚ฐ ๋น„์œจ + { + updateAppConfigDraft((current) => ({ + ...current, + gestureShortcuts: { + ...current.gestureShortcuts, + openSearch: event.target.value, + }, + })); + }} + /> + ํ†ตํ•ฉ ๊ฒ€์ƒ‰์„ ์—ฝ๋‹ˆ๋‹ค. ์˜ˆ: `Mod+K`, `Alt+/` + + + ์˜ค๋ฅธ์ชฝ ๊ฐ€์šด๋ฐ ์™ผ์ชฝ ๋‹น๊ธฐ๊ธฐ ์•ก์…˜ + { + updateAppConfigDraft((current) => ({ + ...current, + gestureShortcuts: { + ...current.gestureShortcuts, + openWindowSearch: event.target.value, + }, + })); + }} + /> + ์„ ํƒํ•œ ํ•ญ๋ชฉ์„ `Window UI`๋กœ ์—ฌ๋Š” ๊ฒ€์ƒ‰์„ ์—ฝ๋‹ˆ๋‹ค. ์˜ˆ: `Mod+Shift+K` + + + + + + + ); + + const settingsMenu = ( +
+ {hasAccess ? ( + <> + + + + + ) : null} + + + + +
+ ); + + return ( + <> +
+ + + + + settingsMenu} + > + {settingsTriggerButton} + + + ) : ( + tokenTriggerButton + )} + + +
+ + { + setIsRuntimeModalOpen(false); + }} + > + +
+
+ + ์‹คํ–‰ + {runningRuntimeCount} +
+
+ + ๋Œ€๊ธฐ + {queuedRuntimeCount} +
+
+ + ํ™œ์„ฑ + {runtimeSessionCount} +
+
+ +
+ ์‹คํ–‰ ์ค‘ ์š”์ฒญ + {chatRuntimeSnapshot && chatRuntimeSnapshot.running.length > 0 ? ( + chatRuntimeSnapshot.running.map((item) => ( +
+
+
+ {getConversationLabel(item.sessionId)} + {item.summary || '์š”์•ฝ ์—†์Œ'} +
+ + + ์‹คํ–‰ + + {formatRuntimeRelativeLabel(item.startedAt)} +
+
+ + + + +
+
+ )) + ) : ( + ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์š”์ฒญ์ด ์—†์Šต๋‹ˆ๋‹ค. + )} +
+
+ ๋Œ€๊ธฐ ์š”์ฒญ + {chatRuntimeSnapshot && chatRuntimeSnapshot.queued.length > 0 ? ( + chatRuntimeSnapshot.queued.map((item) => ( +
+
+
+ {getConversationLabel(item.sessionId)} + {item.summary || '์š”์•ฝ ์—†์Œ'} +
+ + + ๋Œ€๊ธฐ + + {formatRuntimeRelativeLabel(item.enqueuedAt)} +
+
+ +
+
+ )) + ) : ( + ํ˜„์žฌ ๋Œ€๊ธฐ ์ค‘์ธ ์š”์ฒญ์ด ์—†์Šต๋‹ˆ๋‹ค. + )} +
+
+ ์ตœ๊ทผ ์ž‘์—… + {chatRuntimeSnapshot && chatRuntimeSnapshot.recent.length > 0 ? ( + chatRuntimeSnapshot.recent.map((item) => ( +
+
+
+ {getConversationLabel(item.sessionId)} + {item.summary || '์š”์•ฝ ์—†์Œ'} +
+ {buildRuntimeTerminalLabel(item.terminalStatus)} + + {formatRuntimeRelativeLabel(item.lastUpdatedAt)} + +
+
+ + + + +
+
+ )) + ) : ( + ์ตœ๊ทผ ์ข…๋ฃŒ๋œ ์ž‘์—…์ด ์—†์Šต๋‹ˆ๋‹ค. + )} +
+
+
+ + { + setIsRuntimeLogDrawerOpen(false); + setRuntimeLogDetail(null); + setRuntimeLogError(''); + }} + styles={{ + body: { + padding: 24, + }, + }} + > + {runtimeLogLoading ? ( + ๋กœ๊ทธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค. + ) : runtimeLogError ? ( + {runtimeLogError} + ) : runtimeLogDetail ? ( + + {runtimeLogDetail.item?.sessionId ? ( + + ) : null} +
+ ์š”์ฒญ: {runtimeLogDetail.item?.requestId ?? '-'} + ์„ธ์…˜: {runtimeLogDetail.item?.sessionId ?? '-'} + ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹ : {formatRuntimeTimestamp(runtimeLogDetail.lastUpdatedAt)} + + ์ข…๋ฃŒ ์ƒํƒœ: {buildRuntimeTerminalLabel(runtimeLogDetail.terminalStatus)} + +
+ + {runtimeLogDetail.item?.summary ?? '์š”์•ฝ ์—†์Œ'} + +
+              {runtimeLogDetail.logs.length > 0 ? runtimeLogDetail.logs.join('\n') : '์•„์ง ๊ธฐ๋ก๋œ ๋กœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'}
+            
+
+ ) : ( + ํ‘œ์‹œํ•  ๋กœ๊ทธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. + )} +
+ + { + setSettingsModalOpen(false); + }} + > + + {activeSettingsModal === 'appSettings' ? ( + <> + { + setActiveAppSettingsCategory(value as AppSettingsCategoryKey); + }} + /> + ({ + value: option.value, + disabled: option.disabled, + label: ( +
+ {option.label} +
+ ), + }))} + getPopupContainer={(triggerNode) => triggerNode.closest('.app-chat-panel') ?? document.body} + disabled={chatTypeOptions.length === 0} + onChange={onSelectChatType} + /> + +
+
+
+
+ + + {chatTypeOptions.length === 0 ? ( + + ) : null} + +
0 ? ' app-chat-panel__composer-input-shell--with-queue' : '' + }`} + > + {queuedRequests.length > 0 ? ( +
+
+ ๋Œ€๊ธฐ์—ด {queuedRequests.length}๊ฑด +
+
+ {queuedRequests.map((item) => ( +
+
+ {item.order} + {summarizeQueuedText(item.text)} +
+
+
+
+ ))} +
+
+ ) : null} + + { + onDraftChange(event.target.value); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' || event.nativeEvent.isComposing) { + return; + } + + if (!event.ctrlKey) { + event.stopPropagation(); + return; + } + + if (isComposerAttachmentUploading) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + onSend(); + }} + /> + + +
+ + {composerAttachments.length > 0 ? ( +
+ {composerAttachments.map((attachment) => ( +
+ {attachment.name} +
+ ))} +
+ ) : null} + + + + ); +} diff --git a/src/app/main/mainChatPanel/ChatPreviewBody.tsx b/src/app/main/mainChatPanel/ChatPreviewBody.tsx new file mode 100755 index 0000000..c8d37f6 --- /dev/null +++ b/src/app/main/mainChatPanel/ChatPreviewBody.tsx @@ -0,0 +1,303 @@ +import { DownloadOutlined, EyeOutlined } from '@ant-design/icons'; +import { Alert, Button, Empty, Space, Spin, Typography } from 'antd'; +import { InlineImage } from '../../../components/common/InlineImage'; +import { MarkdownPreviewContent } from '../../../components/markdownPreview/MarkdownPreviewContent'; +import { CodexDiffBlock } from '../../../components/previewer'; +import { inferCodeLanguage, renderEditorBlock } from '../../../components/previewer/renderers'; +import '../../../components/previewer/PreviewerUI.css'; + +const { Paragraph, Text } = Typography; + +export type ChatPreviewKind = 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file'; + +export type ChatPreviewTarget = { + label: string; + url: string; + kind: ChatPreviewKind; +}; + +function resolvePreviewErrorMessage(previewError: string) { + const normalized = previewError.trim(); + + if (!normalized) { + return ''; + } + + if (/^\s*403\b/.test(normalized) || normalized.includes('๊ถŒํ•œ์œผ๋กœ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค')) { + return '๊ถŒํ•œ์ด ์—†๊ฑฐ๋‚˜ ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ๊ฒฝ๋กœ์ž…๋‹ˆ๋‹ค. ์„ธ์…˜ ๋ฆฌ์†Œ์Šค ๊ฒฝ๋กœ์™€ ์ ‘๊ทผ ๊ถŒํ•œ์„ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.'; + } + + if (/^\s*404\b/.test(normalized) || normalized.includes('์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค')) { + return 'ํŒŒ์ผ์ด ์ด๋™๋˜์—ˆ๊ฑฐ๋‚˜ ์•„์ง ์„ธ์…˜ ๋ฆฌ์†Œ์Šค ๊ฒฝ๋กœ์— ์ƒ์„ฑ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๊ฒฝ๋กœ๋ฅผ ๋‹ค์‹œ ํ™•์ธํ•ด ์ฃผ์„ธ์š”.'; + } + + if (/^\s*401\b/.test(normalized) || normalized.includes('์ธ์ฆ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค')) { + return '์ธ์ฆ ์ •๋ณด๊ฐ€ ์—†์–ด์„œ ๋ฌธ์„œ๋ฅผ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'; + } + + return normalized; +} + +function resolvePreviewExtension(target: ChatPreviewTarget) { + const raw = target.label || target.url; + const normalized = raw.toLowerCase().split('?')[0] ?? ''; + const match = normalized.match(/\.([a-z0-9]+)$/i); + return match?.[1] ?? ''; +} + +function resolveCodeLanguage(target: ChatPreviewTarget, previewText: string) { + const extension = resolvePreviewExtension(target); + + if (extension === 'tsx' || extension === 'ts') { + return 'typescript'; + } + + if (extension === 'jsx' || extension === 'js' || extension === 'mjs' || extension === 'cjs') { + return 'javascript'; + } + + if (extension === 'json') { + return 'json'; + } + + if (extension === 'css') { + return 'css'; + } + + if (extension === 'scss') { + return 'scss'; + } + + if (extension === 'html' || extension === 'htm') { + return 'html'; + } + + if (extension === 'md' || extension === 'markdown') { + return 'markdown'; + } + + if (extension === 'java') { + return 'java'; + } + + if (extension === 'kt') { + return 'kotlin'; + } + + if (extension === 'py') { + return 'python'; + } + + if (extension === 'go') { + return 'go'; + } + + if (extension === 'rs') { + return 'rust'; + } + + if (extension === 'sql') { + return 'sql'; + } + + if (extension === 'sh' || extension === 'bash' || extension === 'zsh') { + return 'bash'; + } + + if (extension === 'yml' || extension === 'yaml') { + return 'yaml'; + } + + if (extension === 'xml') { + return 'xml'; + } + + if (extension === 'diff' || extension === 'patch') { + return 'diff'; + } + + if (/^(diff --git|@@\s|--- a\/|\+\+\+ b\/)/m.test(previewText)) { + return 'diff'; + } + + return inferCodeLanguage(extension || 'text'); +} + +function isAppRouteUrl(url: string) { + if (typeof window === 'undefined') { + return false; + } + + try { + const parsed = new URL(url, window.location.href); + const pathname = parsed.pathname.toLowerCase(); + const hasKnownFileExtension = /\.[a-z0-9]+$/i.test(pathname); + return parsed.origin === window.location.origin && !hasKnownFileExtension; + } catch { + return false; + } +} + +function canRenderFramePreview(url: string) { + if (typeof window === 'undefined') { + return false; + } + + try { + const parsed = new URL(url, window.location.href); + return parsed.origin === window.location.origin; + } catch { + return false; + } +} + +type ChatPreviewBodyProps = { + target: ChatPreviewTarget | null; + previewText: string; + isPreviewLoading: boolean; + previewError: string; + previewContentType?: string; +}; + +function isHtmlFallbackPreview(target: ChatPreviewTarget, previewText: string, previewContentType: string | undefined) { + const extension = resolvePreviewExtension(target); + const normalizedContentType = previewContentType?.toLowerCase() ?? ''; + const normalizedPreview = previewText.trimStart().toLowerCase(); + + if (extension === 'html' || extension === 'htm' || target.kind === 'markdown') { + return false; + } + + const looksLikeHtml = + normalizedContentType.includes('text/html') || + normalizedPreview.startsWith('; + } + + if (isPreviewLoading) { + return ( +
+ + preview๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘์ž…๋‹ˆ๋‹ค. +
+ ); + } + + if (previewError) { + return ( + + ); + } + + if (isHtmlFallbackPreview(target, previewText, previewContentType)) { + return ( + + ); + } + + if (target.kind === 'image') { + return ( + + ); + } + + if (target.kind === 'video') { + return