diff --git a/.gitignore b/.gitignore index 8e7e55a..464ee9f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,8 @@ backups/ credentials/ # Ops runtime artifacts +ops/alerts/sent/*.json +ops/alerts/summaries/ ops/commands/outbox/ ops/commands/results.jsonl ops/commands/state/completed/ @@ -46,6 +48,7 @@ ops/commands/state/grants/ ops/commands/state/pending/ ops/commands/state/processing/ ops/state/browser_requests.jsonl +ops/state/briefing_locks/ ops/state/cron_backup_*.txt ops/state/state.json ops/state/issues.json diff --git a/.openclaw/skills/job-pipeline/SKILL.md b/.openclaw/skills/job-pipeline/SKILL.md new file mode 100644 index 0000000..7a023de --- /dev/null +++ b/.openclaw/skills/job-pipeline/SKILL.md @@ -0,0 +1,33 @@ +--- +name: job-pipeline +description: Track job applications, recruiters, stages, notes, follow-ups, and weekly summaries through the existing Moltbot Telegram bridge. +version: 1.0.0 +--- + +# Job Pipeline CRM + +Use the existing bridge route for single-user job process tracking. This is local-first and stores data in the existing personal SQLite database. + +## Command + +```bash +node /home/node/.openclaw/workspace/scripts/bridge.js job "" +``` + +## Telegram Examples + +- `지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://example.com/jobs/1` +- `Acme 현재 단계 interview_1로 변경` +- `리크루터 메모 저장 Acme 다음 주에 답장 필요` +- `Acme 다음액션=포트폴리오 보내기 마감=2026-05-03` +- `지원: 목록` +- `지원: 검색 react remote` +- `지원: 상세 Acme` +- `지원: 주간요약` + +## Boundaries + +- Do not auto-apply to jobs. +- Do not send outbound email. +- Do not widen Telegram permissions. +- Keep secrets and credentials out of the repository. diff --git a/AGENTS.md b/AGENTS.md index f526e1d..6b5c91e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,8 @@ - Never expose internal execution traces, raw shell commands, stderr dumps, or JSON tool errors in user replies. ## Telegram Router -- Prefix and operating commands (`메모/기록/학습/단어/실행/작업/검토/점검/출시/배포/프로젝트/요약/리포트/프롬프트/질문/운영/상태/링크`) must call `sh scripts/bridge_cmd.sh auto ""` first. +- Prefix and operating commands (`메모/기록/학습/단어/실행/작업/검토/점검/출시/배포/프로젝트/요약/리포트/프롬프트/질문/운영/상태/링크/지원/지원처/채용/구인/job`) must call `scripts/bridge_cmd.sh auto ""` first. +- Job pipeline natural-language updates that mention hiring steps (`서류/면접/면담/캐주얼면접/코테/오퍼/탈락/팔로업/면접일/비즈리치`) must also call `scripts/bridge_cmd.sh auto ""` first, even without a prefix. Do not wrap this bridge call with `sh`. - If `MOLTBOT_BOT_ID` is `bot-daily-bak` or `bot-codex`, every non-empty Telegram input must go through bridge first. - Also route browser/docs/library/project/install/bootstrap/persona requests through bridge first. - Strip transport wrappers like `[Telegram ...] ... [message_id: ...]` before routing. diff --git a/data/config.json b/data/config.json index 7bcf074..51e0516 100644 --- a/data/config.json +++ b/data/config.json @@ -67,7 +67,11 @@ "workout": "운동:", "media": "콘텐츠:", "place": "식당:", - "restaurant": "맛집:" + "restaurant": "맛집:", + "job": "지원:", + "jobPipeline": "지원처:", + "recruiting": "채용:", + "jobEn": "job:" }, "commandAllowlist": { "enabled": true, @@ -86,7 +90,8 @@ "routine", "workout", "media", - "place" + "place", + "job" ], "autoRoutes": [ "word", @@ -106,7 +111,8 @@ "routine", "workout", "media", - "place" + "place", + "job" ] }, "hubDelegation": { @@ -130,7 +136,8 @@ "routine": "daily", "workout": "daily", "media": "daily", - "place": "daily" + "place": "daily", + "job": "daily" } }, "naturalLanguageRouting": { @@ -141,6 +148,7 @@ "inferTodo": true, "inferRoutine": true, "inferWorkout": true, + "inferJob": true, "inferPersona": true, "inferBrowser": true, "inferStatus": true, diff --git a/docker-compose.yml b/docker-compose.yml index 61afff4..7800967 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -421,7 +421,7 @@ services: MOLTBOT_BOT_ROLE: supervisor BRIDGE_ALLOWLIST_ENABLED: "true" BRIDGE_ALLOWLIST_DIRECT_COMMANDS: "auto" - BRIDGE_ALLOWLIST_AUTO_ROUTES: "word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place" + BRIDGE_ALLOWLIST_AUTO_ROUTES: "word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place,job" BRIDGE_BLOCK_HINT: "데일리 허브에서 처리할 수 없는 명령입니다. 템플릿/프리픽스를 확인해주세요." volumes: - openclaw_daily_state:/home/node/.openclaw:rw @@ -850,7 +850,7 @@ services: MOLTBOT_BOT_ROLE: supervisor BRIDGE_ALLOWLIST_ENABLED: "true" BRIDGE_ALLOWLIST_DIRECT_COMMANDS: "auto" - BRIDGE_ALLOWLIST_AUTO_ROUTES: "word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place" + BRIDGE_ALLOWLIST_AUTO_ROUTES: "word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place,job" BRIDGE_BLOCK_HINT: "데일리(백업) 허브에서 처리할 수 없는 명령입니다. 템플릿/프리픽스를 확인해주세요." volumes: - openclaw_daily_bak_state:/home/node/.openclaw:rw diff --git a/docs/job_pipeline_crm.md b/docs/job_pipeline_crm.md new file mode 100644 index 0000000..83b0c20 --- /dev/null +++ b/docs/job_pipeline_crm.md @@ -0,0 +1,27 @@ +# Job Pipeline CRM + +Telegram에서 `지원:`, `지원처:`, `채용:`, `job:` prefix로 채용 진행 상황을 기록한다. + +## Examples + +- `지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://example.com/jobs/1` +- `Acme 현재 단계 interview_1로 변경` +- `리크루터 메모 저장 Acme 다음 주에 답장 필요` +- `Acme 다음액션=포트폴리오 보내기 마감=2026-05-03` +- `지원: 목록` +- `지원: 검색 react remote` +- `지원: 상세 Acme` +- `지원: 주간요약` + +## Storage + +- Uses the existing local SQLite file: `data/personal/personal.sqlite` +- Adds `job_*` tables on first use through the existing personal schema path. +- No secrets are stored in the repository. + +## Rollout + +1. Run local checks for the new route. +2. Inject OpenClaw runtime config if needed. +3. Restart only the daily runtime first. +4. Smoke test `지원: 도움말`, `지원: 목록`, `가계: 점심 1200엔`, and `상태:`. diff --git a/ops/alerts/sent/2026-02-16T19-00-01.636Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-16T19-00-01.636Z_bot-dev_bot_down.json deleted file mode 100644 index eb258cd..0000000 --- a/ops/alerts/sent/2026-02-16T19-00-01.636Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_82c4e767-82a7-4348-935d-07162ba950ee", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-16T19:00:01.636Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-16T19:00:01.636Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-16T19:00:01.636Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-16T21-30-00.999Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-16T21-30-00.999Z_bot-dev_bot_down.json deleted file mode 100644 index f09b39e..0000000 --- a/ops/alerts/sent/2026-02-16T21-30-00.999Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_15b293f8-87ed-4223-a78b-825daa8742c1", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-16T21:30:00.999Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-16T21:30:00.999Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-16T21:30:00.999Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-16T23-30-01.436Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-16T23-30-01.436Z_bot-dev_bot_down.json deleted file mode 100644 index 29b264a..0000000 --- a/ops/alerts/sent/2026-02-16T23-30-01.436Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_85b7dd0b-1a5c-4051-af70-4e64300e18a8", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-16T23:30:01.436Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-16T23:30:01.436Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-16T23:30:01.436Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-17T02-00-01.555Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-17T02-00-01.555Z_bot-dev_bot_down.json deleted file mode 100644 index 414eb71..0000000 --- a/ops/alerts/sent/2026-02-17T02-00-01.555Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_f1260664-9291-46e0-816a-d8fa23914557", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-17T02:00:01.555Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-17T02:00:01.555Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-17T02:00:01.555Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-17T04-30-01.444Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-17T04-30-01.444Z_bot-dev_bot_down.json deleted file mode 100644 index 7ab3880..0000000 --- a/ops/alerts/sent/2026-02-17T04-30-01.444Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_4e9aea5f-9b5f-466e-ae3d-9d91608f7efe", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-17T04:30:01.444Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-17T04:30:01.444Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-17T04:30:01.444Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-17T07-00-00.712Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-17T07-00-00.712Z_bot-dev_bot_down.json deleted file mode 100644 index 1a49744..0000000 --- a/ops/alerts/sent/2026-02-17T07-00-00.712Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_ae2ec447-bc6a-4a6f-b052-71cb8f5ecac2", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-17T07:00:00.712Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-17T07:00:00.712Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-17T07:00:00.712Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/ops/alerts/sent/2026-02-17T09-00-01.062Z_bot-dev_bot_down.json b/ops/alerts/sent/2026-02-17T09-00-01.062Z_bot-dev_bot_down.json deleted file mode 100644 index 02213ab..0000000 --- a/ops/alerts/sent/2026-02-17T09-00-01.062Z_bot-dev_bot_down.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "alert_id": "alert_02c70666-4cc4-4e8b-83e9-1424ca51ea6b", - "issue_id": "bot-dev:bot_down", - "severity": "P1", - "created_at": "2026-02-17T09:00:01.062Z", - "suppressed": false, - "suppression_reason": null, - "message_markdown": "[P1] Incident — bot-dev — bot-dev:bot_down\n\nImpact:\n- No heartbeat observed for over 6 hours.\n\nEvidence:\n- First seen: 2026-02-16T19:00:01.636Z\n- Last seen: 2026-02-17T09:00:01.062Z\n- Run ID: -\n- Error fingerprint: bot_down\n- Log paths:\n - /Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json\n\nLikely cause (log-based):\n- No heartbeat observed for over 6 hours.\n\nImmediate mitigation checklist:\n1) Verify upstream/service dependency health and recent config changes.\n2) Trigger one controlled rerun and compare run_id/evidence delta.\n3) If failure repeats, escalate with runbook and keep issue open.\n\nNext update:\n- I will re-check at the next 30-minute scan. (Asia/Tokyo, generated 2026-02-17T09:00:01.062Z)", - "evidence": { - "run_ids": [], - "log_paths": [ - "/Users/moltbot/Projects/Moltbot_Workspace/logs/bot-dev/heartbeat.json" - ] - }, - "decision_rule": "P1 immediate alert" -} diff --git a/package-lock.json b/package-lock.json index 8ed5e17..fbd9dc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "1.0.0", "license": "ISC", "workspaces": [ - "apps/*", "packages/*" ], "dependencies": { "ai": "^6.0.69", - "axios": "^1.13.5", + "axios": "^1.15.2", "date-fns": "^4.1.0", - "google-auth-library": "^9.14.2", + "follow-redirects": "^1.16.0", + "google-auth-library": "^10.6.2", "google-spreadsheet": "^5.0.2", "moment": "^2.30.1", "playwright": "^1.58.1", @@ -140,14 +140,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/base64-js": { @@ -210,6 +210,15 @@ "node": ">= 0.8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -339,10 +348,33 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -375,6 +407,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -399,33 +443,31 @@ } }, "node_modules/gaxios": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", - "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9", - "uuid": "^9.0.1" + "node-fetch": "^3.3.2" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/gcp-metadata": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", - "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", "license": "Apache-2.0", "dependencies": { - "gaxios": "^6.1.1", - "google-logging-utils": "^0.0.2", + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/get-intrinsic": { @@ -466,26 +508,26 @@ } }, "node_modules/google-auth-library": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", - "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^6.1.1", - "gcp-metadata": "^6.1.0", - "gtoken": "^7.0.0", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", "jws": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=18" } }, "node_modules/google-logging-utils": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", - "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", "license": "Apache-2.0", "engines": { "node": ">=14" @@ -521,19 +563,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gtoken": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", - "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", - "license": "MIT", - "dependencies": { - "gaxios": "^6.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -586,18 +615,6 @@ "node": ">= 14" } }, - "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==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -691,24 +708,42 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", "dependencies": { - "whatwg-url": "^5.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": "4.x || >=6.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/playwright": { @@ -742,10 +777,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/safe-buffer": { "version": "5.2.1", @@ -767,39 +805,13 @@ ], "license": "MIT" }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "engines": { + "node": ">= 8" } }, "node_modules/zod": { diff --git a/package.json b/package.json index 11e63a7..9335b97 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,13 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "npm run -s test:core", + "test:smoke": "node scripts/test_check_package_scripts.js && npm run -s check:scripts && node scripts/test_bridge_ops_capability_routes.js && node scripts/test_anki_connect.js", + "test:core": "npm run -s test:v1-release && npm run -s test:ops && npm run -s test:scope:anki", + "test:full": "npm run -s check:scripts && npm run -s check:deps-light && npm run -s test:core && npm run -s test:news && npm run -s test:anki && npm run -s test:token-austerity && npm run -s check:runtime-artifact-tracking", + "test:changed": "node scripts/run_changed_tests.js", "test:anki": "node scripts/test_anki_connect.js && node scripts/test_anki_pipeline.js", + "test:scope:anki": "node scripts/test_anki_connect.js", "test:bridge-allowlist": "node scripts/test_bridge_allowlist.js", "test:bridge-default-route": "node scripts/test_bridge_default_route.js", "test:bridge-role-allowlist": "node scripts/test_bridge_role_allowlist.js", @@ -45,7 +50,7 @@ "news:event": "node scripts/news_digest.js event", "news:status": "node scripts/news_digest.js status", "test:news-target": "node scripts/test_news_digest_target.js", - "test:personal": "node scripts/test_personal_storage.js && node scripts/test_personal_finance.js && node scripts/test_personal_todo.js && node scripts/test_personal_routine.js && node scripts/test_personal_workout.js && node scripts/test_personal_media_place.js && node scripts/test_personal_migrate_legacy.js", + "test:personal": "node scripts/test_personal_storage.js && node scripts/test_personal_finance.js && node scripts/test_personal_todo.js && node scripts/test_personal_routine.js && node scripts/test_personal_workout.js && node scripts/test_personal_media_place.js && node scripts/test_personal_job_pipeline.js && node scripts/test_personal_migrate_legacy.js", "test:no-prefix-routing-report": "node scripts/test_no_prefix_routing_report.js", "test:v1-release": "node scripts/test_oai_api_router.js && node scripts/test_bridge_natural_language_routing.js && npm run -s test:no-prefix-routing-report && node scripts/test_bridge_allowlist.js && node scripts/test_bridge_hub_delegation.js && node scripts/test_bridge_record_route.js && node scripts/test_bridge_news_route.js && node scripts/test_bridge_report_default_by_runtime.js && node scripts/test_bridge_word_typo_reask.js && node scripts/test_word_enrichment.js && npm run -s test:personal", "ops:standby-check": "node scripts/standby_cutover_check.js", @@ -74,6 +79,9 @@ "prompt:web": "node scripts/prompt_form_webapp.js", "prompt:web:docker": "PROMPT_FORM_HOST=0.0.0.0 node scripts/prompt_form_webapp.js", "ops:worker": "node scripts/ops_host_worker.js", + "ops:gc": "node scripts/ops_runtime_gc.js", + "ops:dashboard": "node scripts/ops_dashboard.js", + "ops:alerts:compact": "node scripts/ops_alerts_compact.js", "ops:daily:scan": "node scripts/ops_daily_supervisor.js scan", "ops:daily:briefing:morning": "node scripts/ops_daily_supervisor.js briefing --type morning", "ops:daily:briefing:evening": "node scripts/ops_daily_supervisor.js briefing --type evening", @@ -98,6 +106,9 @@ "env:migrate-runtime": "node scripts/runtime_env_migrate.js", "check:runtime-artifact-tracking": "node scripts/check_runtime_artifact_tracking.js", "fix:runtime-artifact-tracking": "node scripts/check_runtime_artifact_tracking.js --fix", + "check:scripts": "node scripts/check_package_scripts.js", + "check:deps-light": "node scripts/dependency_audit_light.js", + "scripts:manifest": "node scripts/package_scripts_manifest.js", "check:container-isolation-refs": "node scripts/check_container_isolation_refs.js", "check:prompt-budget": "node scripts/check_prompt_budget.js", "check:secrets-scan-workflow": "node scripts/check_secrets_scan_workflow.js", @@ -134,11 +145,11 @@ "test:portfolio-content": "node scripts/test_portfolio_content_sections.js", "test:ops-file-control": "node scripts/test_ops_file_policy_tiers.js && node scripts/test_ops_file_approval_flow.js && node scripts/test_ops_file_external_preflight.js && node scripts/test_ops_file_git_gating.js && node scripts/test_bridge_ops_file_control.js && node scripts/test_bridge_approve_command.js", "test:ops-capability": "node scripts/test_bridge_ops_capability_routes.js && node scripts/test_ops_capability_worker.js && node scripts/test_ops_bot_dispatch_capability.js && node scripts/test_ops_approval_grant_flow.js && node scripts/test_ops_notification_policy.js", - "test:ops": "node scripts/test_ops_logger_redaction.js && node scripts/test_ops_fingerprint_stability.js && node scripts/test_ops_issue_dedupe.js && node scripts/test_ops_quiet_hours.js && node scripts/test_ops_schema_validation.js && node scripts/test_ops_daily_supervisor_e2e.js && node scripts/test_ops_missing_latest_and_heartbeat.js && node scripts/test_ops_no_signal_placeholder.js && node scripts/test_ops_bot_down_classification_v2.js && node scripts/test_ops_briefing_generation.js && node scripts/test_ops_auto_remediation.js && npm run -s test:ops-file-control && npm run -s test:ops-capability && npm run -s test:bridge-hub-delegation", - "bot:daily:auto": "node apps/bot-daily/src/main.js", - "bot:dev:work": "node apps/bot-dev/src/main.js", - "bot:anki:word": "node apps/bot-anki/src/main.js", - "bot:research:news": "node apps/bot-research/src/main.js", + "test:ops": "node scripts/test_ops_logger_redaction.js && node scripts/test_ops_fingerprint_stability.js && node scripts/test_ops_issue_dedupe.js && node scripts/test_ops_quiet_hours.js && node scripts/test_ops_schema_validation.js && node scripts/test_ops_daily_supervisor_e2e.js && node scripts/test_ops_missing_latest_and_heartbeat.js && node scripts/test_ops_no_signal_placeholder.js && node scripts/test_ops_bot_down_classification_v2.js && node scripts/test_ops_briefing_generation.js && node scripts/test_ops_auto_remediation.js && node scripts/test_ops_runtime_gc.js && node scripts/test_ops_dashboard.js && node scripts/test_ops_alerts_compact.js && node scripts/test_run_changed_tests.js && node scripts/test_package_scripts_manifest.js && npm run -s test:ops-file-control && npm run -s test:ops-capability && npm run -s test:bridge-hub-delegation", + "bot:daily:auto": "MOLTBOT_BOT_ID=bot-daily node scripts/bridge.js auto", + "bot:dev:work": "MOLTBOT_BOT_ID=bot-dev node scripts/bridge.js work", + "bot:anki:word": "MOLTBOT_BOT_ID=bot-anki node scripts/bridge.js word", + "bot:research:news": "MOLTBOT_BOT_ID=bot-research node scripts/bridge.js news", "ops:approvals:gc": "node scripts/ops_approvals_gc.js", "check:openclaw-profile-templates": "node scripts/check_openclaw_profile_templates.js", "test:token-austerity": "node scripts/test_bridge_model_policy.js && node scripts/test_model_cost_latency_dashboard_routes.js && node scripts/test_ops_session_rotation.js && node scripts/test_prompt_budget_guard.js && npm run -s check:prompt-budget", @@ -149,25 +160,29 @@ "runtime:dev:status": "node scripts/runtime_bot.js dev status", "runtime:dev:start": "node scripts/runtime_bot.js dev start", "runtime:dev:stop": "node scripts/runtime_bot.js dev stop", + "runtime:dev:restart": "node scripts/runtime_bot.js dev restart", "runtime:anki:status": "node scripts/runtime_bot.js anki status", "runtime:anki:start": "node scripts/runtime_bot.js anki start", "runtime:anki:stop": "node scripts/runtime_bot.js anki stop", + "runtime:anki:restart": "node scripts/runtime_bot.js anki restart", "runtime:research:status": "node scripts/runtime_bot.js research status", "runtime:research:start": "node scripts/runtime_bot.js research start", "runtime:research:stop": "node scripts/runtime_bot.js research stop", - "cron:dev:keys-todo": "node apps/bot-dev/src/cron_keys_todo.js", - "cron:daily:digest": "node apps/bot-daily/src/cron_digest.js", - "cron:research:news-send": "node apps/bot-research/src/cron_news_send.js", - "cron:research:news-event": "node apps/bot-research/src/cron_news_event.js" + "runtime:research:restart": "node scripts/runtime_bot.js research restart", + "cron:dev:keys-todo": "MOLTBOT_BOT_ID=bot-dev node scripts/dev_thirdparty_keys_todolist.js", + "cron:daily:digest": "MOLTBOT_BOT_ID=bot-daily node scripts/daily_telegram_digest.js", + "cron:research:news-send": "MOLTBOT_BOT_ID=bot-research node scripts/news_digest.js send", + "cron:research:news-event": "MOLTBOT_BOT_ID=bot-research node scripts/news_digest.js event" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "ai": "^6.0.69", - "axios": "^1.13.5", + "axios": "^1.15.2", "date-fns": "^4.1.0", - "google-auth-library": "^9.14.2", + "follow-redirects": "^1.16.0", + "google-auth-library": "^10.6.2", "google-spreadsheet": "^5.0.2", "moment": "^2.30.1", "playwright": "^1.58.1", @@ -176,7 +191,6 @@ }, "private": true, "workspaces": [ - "apps/*", "packages/*" ] } diff --git a/packages/core-policy/src/bridge_defaults.js b/packages/core-policy/src/bridge_defaults.js index 706a289..99c5938 100644 --- a/packages/core-policy/src/bridge_defaults.js +++ b/packages/core-policy/src/bridge_defaults.js @@ -4,11 +4,11 @@ const DEFAULT_COMMAND_ALLOWLIST = Object.freeze({ enabled: true, directCommands: [ 'auto', 'work', 'inspect', 'deploy', 'project', 'ops', 'word', - 'news', 'prompt', 'finance', 'todo', 'routine', 'workout', 'media', 'place', + 'news', 'prompt', 'finance', 'todo', 'routine', 'workout', 'media', 'place', 'job', ], autoRoutes: [ 'word', 'memo', 'news', 'report', 'work', 'inspect', 'deploy', 'project', - 'prompt', 'link', 'status', 'ops', 'finance', 'todo', 'routine', 'workout', 'media', 'place', + 'prompt', 'link', 'status', 'ops', 'finance', 'todo', 'routine', 'workout', 'media', 'place', 'job', ], }); @@ -26,6 +26,7 @@ const DEFAULT_NATURAL_LANGUAGE_ROUTING = Object.freeze({ inferTodo: true, inferRoutine: true, inferWorkout: true, + inferJob: true, inferPersona: true, inferBrowser: true, inferSchedule: true, diff --git a/packages/core-routing/src/route_profile_map.js b/packages/core-routing/src/route_profile_map.js index 7455ae3..8241e37 100644 --- a/packages/core-routing/src/route_profile_map.js +++ b/packages/core-routing/src/route_profile_map.js @@ -17,6 +17,7 @@ const DEFAULT_ROUTE_PROFILE_MAP = Object.freeze({ workout: 'daily', media: 'daily', place: 'daily', + job: 'daily', }); module.exports = { diff --git a/scripts/anki_connect.js b/scripts/anki_connect.js index 56bce88..ed27149 100644 --- a/scripts/anki_connect.js +++ b/scripts/anki_connect.js @@ -13,6 +13,10 @@ class AnkiConnect { this.port = port; this.fallbackHosts = this.buildFallbackHosts(host); this.modelFieldCache = new Map(); + this.deckAliasMap = new Map([ + ['toeic_ai', 'TOEIC_AI'], + ['toeic-ai', 'TOEIC_AI'], + ]); } buildFallbackHosts(primaryHost) { @@ -150,10 +154,255 @@ class AnkiConnect { ); } + normalizeDeckName(deckName) { + const raw = String(deckName || '').trim(); + if (!raw) return raw; + return this.deckAliasMap.get(raw.toLowerCase()) || raw; + } + + async invokeRetry(action, params = {}, options = {}) { + const retries = Number.isFinite(Number(options.retries)) + ? Math.max(0, Number(options.retries)) + : 3; + const delayMs = Number.isFinite(Number(options.delayMs)) + ? Math.max(0, Number(options.delayMs)) + : 250; + const accept = typeof options.accept === 'function' + ? options.accept + : () => true; + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + const result = await this.invoke(action, params); + if (accept(result)) { + return { + ok: true, + result, + attempts: attempt + 1, + }; + } + lastError = new Error(`${action} response is not ready`); + } catch (error) { + lastError = error; + } + if (attempt < retries && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + return { + ok: false, + result: null, + error: lastError, + attempts: retries + 1, + }; + } + + async verifyAddedDeck(noteId, deckName, options = {}) { + const requestedDeck = this.normalizeDeckName(deckName); + const skipped = (reason) => ({ + requestedDeck, + actualDeck: null, + deckMismatchRecovered: false, + deckVerificationSkipped: true, + deckVerificationSkipReason: reason, + }); + const numericNoteId = Number(noteId); + if (!Number.isFinite(numericNoteId) || numericNoteId <= 0) { + return skipped('invalid_note_id'); + } + + const retryOptions = { + retries: Number.isFinite(Number(options.deckVerificationRetries)) + ? Number(options.deckVerificationRetries) + : 3, + delayMs: Number.isFinite(Number(options.deckVerificationDelayMs)) + ? Number(options.deckVerificationDelayMs) + : 250, + }; + const noteRead = await this.invokeRetry('notesInfo', { notes: [numericNoteId] }, { + ...retryOptions, + accept: (result) => Array.isArray(result) && result.length > 0 && result[0], + }); + if (!noteRead.ok) { + debugLog('Anki deck verification skipped (notesInfo not ready):', noteRead.error && noteRead.error.message); + return skipped('notes_info_unavailable'); + } + + const noteInfo = Array.isArray(noteRead.result) ? noteRead.result[0] : null; + const cardIds = noteInfo && Array.isArray(noteInfo.cards) + ? noteInfo.cards.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0) + : []; + let actualDeck = String((noteInfo && (noteInfo.deckName || noteInfo.deck)) || '').trim(); + + if (cardIds.length > 0) { + const cardsRead = await this.invokeRetry('cardsInfo', { cards: cardIds }, { + ...retryOptions, + accept: (result) => Array.isArray(result) && result.length > 0, + }); + if (cardsRead.ok) { + const cardInfo = cardsRead.result.find((card) => card && (card.deckName || card.deck)) || null; + actualDeck = String((cardInfo && (cardInfo.deckName || cardInfo.deck)) || actualDeck || '').trim(); + } + } + + if (!actualDeck) { + return skipped('deck_info_unavailable'); + } + + if (actualDeck === requestedDeck) { + return { + requestedDeck, + actualDeck, + deckMismatchRecovered: false, + deckVerificationSkipped: false, + }; + } + + let deckMismatchRecovered = false; + if (cardIds.length > 0) { + try { + await this.invoke('changeDeck', { + cards: cardIds, + deck: requestedDeck, + }); + deckMismatchRecovered = true; + actualDeck = requestedDeck; + } catch (error) { + debugLog('Anki deck mismatch recovery failed (non-critical):', error.message); + } + } + + return { + requestedDeck, + actualDeck, + deckMismatchRecovered, + deckVerificationSkipped: false, + }; + } + + async verifyAddedDecks(noteDeckPairs = [], options = {}) { + const pairs = Array.isArray(noteDeckPairs) + ? noteDeckPairs + .map((pair) => ({ + noteId: Number(pair && pair.noteId), + requestedDeck: this.normalizeDeckName(pair && pair.deckName), + })) + .filter((pair) => Number.isFinite(pair.noteId) && pair.noteId > 0 && pair.requestedDeck) + : []; + const skipped = (pair, reason) => ({ + noteId: pair.noteId, + requestedDeck: pair.requestedDeck, + actualDeck: null, + deckMismatchRecovered: false, + deckVerificationSkipped: true, + deckVerificationSkipReason: reason, + }); + if (pairs.length === 0) return []; + + const retryOptions = { + retries: Number.isFinite(Number(options.deckVerificationRetries)) + ? Number(options.deckVerificationRetries) + : 3, + delayMs: Number.isFinite(Number(options.deckVerificationDelayMs)) + ? Number(options.deckVerificationDelayMs) + : 250, + }; + const noteIds = pairs.map((pair) => pair.noteId); + const noteRead = await this.invokeRetry('notesInfo', { notes: noteIds }, { + ...retryOptions, + accept: (result) => Array.isArray(result) && result.length > 0, + }); + if (!noteRead.ok) { + return pairs.map((pair) => skipped(pair, 'notes_info_unavailable')); + } + + const notesById = new Map(); + const allCardIds = []; + for (const note of Array.isArray(noteRead.result) ? noteRead.result : []) { + const noteId = Number(note && note.noteId); + if (!Number.isFinite(noteId)) continue; + const cards = Array.isArray(note.cards) + ? note.cards.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0) + : []; + for (const cardId of cards) allCardIds.push(cardId); + notesById.set(noteId, { + note, + cards, + deckName: String((note && (note.deckName || note.deck)) || '').trim(), + }); + } + + const cardDeckById = new Map(); + if (allCardIds.length > 0) { + const cardsRead = await this.invokeRetry('cardsInfo', { cards: allCardIds }, { + ...retryOptions, + accept: (result) => Array.isArray(result) && result.length > 0, + }); + if (cardsRead.ok) { + for (const card of cardsRead.result) { + const cardId = Number(card && (card.cardId || card.card_id)); + const deck = String((card && (card.deckName || card.deck)) || '').trim(); + if (Number.isFinite(cardId) && deck) cardDeckById.set(cardId, deck); + } + } + } + + const results = []; + const recoveryByDeck = new Map(); + const recoveryResultsByDeck = new Map(); + for (const pair of pairs) { + const noteInfo = notesById.get(pair.noteId); + if (!noteInfo) { + results.push(skipped(pair, 'note_info_unavailable')); + continue; + } + const deckFromCards = noteInfo.cards + .map((cardId) => cardDeckById.get(cardId)) + .find(Boolean); + const actualDeck = deckFromCards || noteInfo.deckName; + if (!actualDeck) { + results.push(skipped(pair, 'deck_info_unavailable')); + continue; + } + const result = { + noteId: pair.noteId, + requestedDeck: pair.requestedDeck, + actualDeck, + deckMismatchRecovered: false, + deckVerificationSkipped: false, + }; + if (actualDeck !== pair.requestedDeck && noteInfo.cards.length > 0) { + if (!recoveryByDeck.has(pair.requestedDeck)) recoveryByDeck.set(pair.requestedDeck, []); + if (!recoveryResultsByDeck.has(pair.requestedDeck)) recoveryResultsByDeck.set(pair.requestedDeck, []); + recoveryByDeck.get(pair.requestedDeck).push(...noteInfo.cards); + recoveryResultsByDeck.get(pair.requestedDeck).push(result); + } + results.push(result); + } + + for (const [deck, cards] of recoveryByDeck.entries()) { + try { + await this.invoke('changeDeck', { cards, deck }); + for (const result of recoveryResultsByDeck.get(deck) || []) { + result.actualDeck = deck; + result.deckMismatchRecovered = true; + } + } catch (error) { + debugLog('Anki batch deck mismatch recovery failed (non-critical):', error.message); + for (const result of recoveryResultsByDeck.get(deck) || []) { + result.deckRecoveryFailed = true; + } + } + } + + return results; + } + async findDuplicateByFront(deckName, front, options = {}) { + const normalizedDeckName = this.normalizeDeckName(deckName); const modelName = String(options.modelName || 'Basic').trim(); const mapped = await this.buildNoteFields(front, '', { modelName }); - const query = `deck:"${this.escapeQueryValue(deckName)}" ${mapped.frontField}:"${this.escapeQueryValue(front)}"`; + const query = `deck:"${this.escapeQueryValue(normalizedDeckName)}" ${mapped.frontField}:"${this.escapeQueryValue(front)}"`; const notes = await this.invoke('findNotes', { query }); if (!Array.isArray(notes) || notes.length === 0) { return null; @@ -176,6 +425,7 @@ class AnkiConnect { async addCard(deckName, front, back, tags = [], options = {}) { const shouldSync = options.sync !== false; + const normalizedDeckName = this.normalizeDeckName(deckName); const modelName = String(options.modelName || 'Basic'); const dedupeMode = String(options.dedupeMode || 'allow').toLowerCase(); let effectiveDedupeMode = dedupeMode; @@ -186,7 +436,7 @@ class AnkiConnect { let duplicate = null; if (dedupeMode !== 'allow') { try { - duplicate = await this.findDuplicateByFront(deckName, front, { modelName }); + duplicate = await this.findDuplicateByFront(normalizedDeckName, front, { modelName }); } catch (error) { debugLog('Anki duplicate scan failed (fallback safe-add):', error.message); // Keep duplicate protection on even when scan fails. @@ -232,12 +482,12 @@ class AnkiConnect { }; } - debugLog(`OpenClaw -> Anki: Adding card to [${deckName}]`); + debugLog(`OpenClaw -> Anki: Adding card to [${normalizedDeckName}]`); let result; try { result = await this.invoke('addNote', { note: { - deckName: deckName, + deckName: normalizedDeckName, modelName: mapped.modelName, fields: mapped.fields, options: { @@ -252,7 +502,7 @@ class AnkiConnect { if (duplicateLike && effectiveDedupeMode !== 'allow') { let resolved = null; try { - resolved = await this.findDuplicateByFront(deckName, front, { modelName }); + resolved = await this.findDuplicateByFront(normalizedDeckName, front, { modelName }); } catch (_) { resolved = null; } @@ -298,11 +548,133 @@ class AnkiConnect { } } + const deckVerification = await this.verifyAddedDeck(result, normalizedDeckName, options); + return { noteId: result, duplicate: false, updated: false, action: 'add', + ...deckVerification, + }; + } + + async addCards(deckName, cards = [], tags = [], options = {}) { + const entries = Array.isArray(cards) ? cards : []; + const shouldSync = options.sync !== false; + const modelName = String(options.modelName || 'Basic'); + const dedupeMode = String(options.dedupeMode || 'allow').toLowerCase(); + const baseTags = Array.isArray(tags) + ? tags.map((v) => String(v || '').trim()).filter(Boolean) + : []; + if (entries.length === 0) { + return { + action: 'batch_add', + count: 0, + added: 0, + results: [], + }; + } + + if (dedupeMode !== 'allow') { + const results = []; + for (const entry of entries) { + const card = entry && typeof entry === 'object' ? entry : {}; + const result = await this.addCard( + card.deckName || deckName, + card.front, + card.back, + [...baseTags, ...(Array.isArray(card.tags) ? card.tags : [])], + { + ...options, + sync: false, + }, + ); + results.push(result); + } + if (shouldSync) { + try { + await this.syncWithDelay(); + } catch (error) { + debugLog('Anki batch sync failed (non-critical):', error.message); + } + } + return { + action: 'batch_add', + count: entries.length, + added: results.filter((row) => row && row.action === 'add').length, + results, + fallbackSequential: true, + }; + } + + const noteDeckPairs = []; + const notes = []; + for (const entry of entries) { + const card = entry && typeof entry === 'object' ? entry : {}; + const normalizedDeckName = this.normalizeDeckName(card.deckName || deckName); + const mapped = await this.buildNoteFields(card.front, card.back, { + modelName: String(card.modelName || modelName), + }); + const cardTags = Array.isArray(card.tags) + ? card.tags.map((value) => String(value || '').trim()).filter(Boolean) + : []; + const cleanTags = [...new Set([...baseTags, ...cardTags])]; + notes.push({ + deckName: normalizedDeckName, + modelName: mapped.modelName, + fields: mapped.fields, + options: { + allowDuplicate: true, + }, + tags: cleanTags, + }); + noteDeckPairs.push({ deckName: normalizedDeckName }); + } + + const noteIds = await this.invoke('addNotes', { notes }); + if (!Array.isArray(noteIds)) { + throw new Error('addNotes response must be an array'); + } + const resultRows = noteIds.map((noteId, index) => { + const numericNoteId = Number(noteId); + const ok = Number.isFinite(numericNoteId) && numericNoteId > 0; + if (ok) noteDeckPairs[index].noteId = numericNoteId; + return { + noteId: ok ? numericNoteId : null, + duplicate: false, + updated: false, + action: ok ? 'add' : 'error', + requestedDeck: noteDeckPairs[index].deckName, + deckVerificationSkipped: true, + deckVerificationSkipReason: ok ? 'pending_batch_verification' : 'add_failed', + }; + }); + + if (shouldSync) { + try { + await this.syncWithDelay(); + } catch (error) { + debugLog('Anki batch sync failed (non-critical):', error.message); + } + } + + const verificationRows = await this.verifyAddedDecks( + noteDeckPairs.filter((pair) => pair.noteId), + options, + ); + const verificationByNoteId = new Map(verificationRows.map((row) => [Number(row.noteId), row])); + const results = resultRows.map((row) => { + const verification = row.noteId ? verificationByNoteId.get(Number(row.noteId)) : null; + return verification ? { ...row, ...verification } : row; + }); + + return { + action: 'batch_add', + count: entries.length, + added: results.filter((row) => row.action === 'add').length, + deckVerificationMode: 'batch', + results, }; } diff --git a/scripts/bridge.js b/scripts/bridge.js index 54463a4..631b637 100644 --- a/scripts/bridge.js +++ b/scripts/bridge.js @@ -32,6 +32,7 @@ const { handleTodoCommand } = require('./personal_todo'); const { handleRoutineCommand } = require('./personal_routine'); const { handleWorkoutCommand } = require('./personal_workout'); const { handleMediaPlaceCommand } = require('./personal_media_place'); +const { handleJobPipelineCommand } = require('./personal_job_pipeline'); const { finalizeTelegramBoundary: finalizeTelegramBoundaryCore } = require('./lib/bridge_output_boundary'); const { parseReportModeCommand: parseReportModeCommandCore, @@ -184,6 +185,7 @@ const { inferTodoIntentPayload: inferTodoIntentPayloadCore, inferRoutineIntentPayload: inferRoutineIntentPayloadCore, inferWorkoutIntentPayload: inferWorkoutIntentPayloadCore, + inferJobIntentPayload: inferJobIntentPayloadCore, inferWorkIntentPayload: inferWorkIntentPayloadCore, inferInspectIntentPayload: inferInspectIntentPayloadCore, inferBrowserIntentPayload: inferBrowserIntentPayloadCore, @@ -257,6 +259,7 @@ const KNOWN_DIRECT_COMMANDS = new Set([ 'workout', 'media', 'place', + 'job', 'anki', 'auto', ]); @@ -1727,6 +1730,10 @@ function inferWorkoutIntentPayload(text) { return inferWorkoutIntentPayloadCore(text); } +function inferJobIntentPayload(text) { + return inferJobIntentPayloadCore(text); +} + function inferWorkIntentPayload(text) { const adaptive = loadRoutingAdaptiveKeywords(); return inferWorkIntentPayloadCore(text, { @@ -1793,6 +1800,7 @@ function inferNaturalLanguageRoute(text, options = {}) { inferTodoIntentPayload, inferRoutineIntentPayload, inferWorkoutIntentPayload, + inferJobIntentPayload, inferWorkIntentPayload, inferInspectIntentPayload, inferBrowserIntentPayload, @@ -2252,6 +2260,8 @@ async function handlePersonalRoute(route, payload, options = {}) { ...baseOptions, kind: 'place', }); + } else if (normalizedRoute === 'job') { + out = await handleJobPipelineCommand(commandText, baseOptions); } else { return { route: normalizedRoute || 'none', @@ -2428,6 +2438,7 @@ module.exports = { inferTodoIntentPayload, inferRoutineIntentPayload, inferWorkoutIntentPayload, + inferJobIntentPayload, inferWorkIntentPayload, inferInspectIntentPayload, runOpsCommand, diff --git a/scripts/check_package_scripts.js b/scripts/check_package_scripts.js new file mode 100644 index 0000000..f72042e --- /dev/null +++ b/scripts/check_package_scripts.js @@ -0,0 +1,132 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +function unquote(value) { + const raw = String(value || '').trim(); + if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) { + return raw.slice(1, -1); + } + return raw; +} + +function collectScriptFileRefs(command) { + const refs = []; + const re = /\b(?:node|bash|sh)\s+("[^"]+"|'[^']+'|[^\s;&|]+)/g; + let match = null; + while ((match = re.exec(String(command || '')))) { + const ref = unquote(match[1]); + if (ref.startsWith('scripts/')) refs.push(ref); + } + return refs; +} + +function collectNpmRunRefs(command) { + const refs = []; + const re = /\bnpm\s+run\s+(?:(?:-[^\s]+\s+)*)("[^"]+"|'[^']+'|[^\s;&|]+)/g; + let match = null; + while ((match = re.exec(String(command || '')))) { + const ref = unquote(match[1]); + if (ref && !ref.startsWith('-')) refs.push(ref); + } + return refs; +} + +function collectAppsRefs(command) { + const refs = []; + const re = /\bapps\/[^\s;&|"'`]+/g; + let match = null; + while ((match = re.exec(String(command || '')))) { + refs.push(match[0]); + } + return refs; +} + +function readPackageJson(packagePath) { + return JSON.parse(fs.readFileSync(packagePath, 'utf8')); +} + +function checkPackageScripts(options = {}) { + const root = path.resolve(options.root || path.resolve(__dirname, '..')); + const packagePath = path.resolve(options.packagePath || path.join(root, 'package.json')); + const pkg = options.packageJson || readPackageJson(packagePath); + const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}; + const issues = []; + + for (const [name, command] of Object.entries(scripts)) { + for (const ref of collectScriptFileRefs(command)) { + if (!fs.existsSync(path.join(root, ref))) { + issues.push({ + type: 'missing_script_file', + script: name, + ref, + }); + } + } + for (const ref of collectAppsRefs(command)) { + issues.push({ + type: 'apps_ref_not_allowed', + script: name, + ref, + }); + } + for (const ref of collectNpmRunRefs(command)) { + if (!Object.prototype.hasOwnProperty.call(scripts, ref)) { + issues.push({ + type: 'missing_npm_script', + script: name, + ref, + }); + } + } + } + + const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : []; + for (const workspace of workspaces) { + const raw = String(workspace || '').trim(); + if (!raw) continue; + if (raw === 'apps/*' || raw.startsWith('apps/')) { + issues.push({ + type: 'apps_workspace_not_allowed', + ref: raw, + }); + continue; + } + const base = raw.endsWith('/*') ? raw.slice(0, -2) : raw; + if (base && !fs.existsSync(path.join(root, base))) { + issues.push({ + type: 'missing_workspace_base', + ref: raw, + }); + } + } + + return { + ok: issues.length === 0, + root, + checkedScripts: Object.keys(scripts).length, + issues, + }; +} + +function main() { + const result = checkPackageScripts(); + const output = JSON.stringify(result, null, 2); + if (result.ok) { + console.log(output); + } else { + console.error(output); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + checkPackageScripts, + collectScriptFileRefs, + collectNpmRunRefs, + collectAppsRefs, +}; diff --git a/scripts/check_runtime_artifact_tracking.js b/scripts/check_runtime_artifact_tracking.js index c2e2a98..fd89590 100644 --- a/scripts/check_runtime_artifact_tracking.js +++ b/scripts/check_runtime_artifact_tracking.js @@ -5,9 +5,11 @@ const { spawnSync } = require('child_process'); const ROOT = path.resolve(__dirname, '..'); const RUNTIME_ARTIFACT_PATTERNS = [ + /^ops\/alerts\/sent\/.*\.json$/, /^ops\/commands\/outbox\//, /^ops\/commands\/results\.jsonl$/, /^ops\/commands\/state\/(?:completed|consumed|grants|pending|processing)\//, + /^ops\/state\/briefing_locks\//, /^ops\/state\/(?:state\.json|issues\.json|leader_snapshot_latest\.json|browser_requests\.jsonl)$/, /^ops\/state\/cron_backup_.*\.txt$/, /^data\/state\/pending_approvals\.json$/, diff --git a/scripts/dependency_audit_light.js b/scripts/dependency_audit_light.js new file mode 100644 index 0000000..34e2c0e --- /dev/null +++ b/scripts/dependency_audit_light.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +function dirSizeBytes(dirPath) { + let stat = null; + try { + stat = fs.lstatSync(dirPath); + } catch (_) { + return 0; + } + if (stat.isSymbolicLink()) return 0; + if (stat.isFile()) return stat.size; + if (!stat.isDirectory()) return 0; + let total = 0; + for (const entry of fs.readdirSync(dirPath)) { + total += dirSizeBytes(path.join(dirPath, entry)); + } + return total; +} + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_) { + return fallback; + } +} + +function topLevelNodeModules(root) { + const nodeModules = path.join(root, 'node_modules'); + if (!fs.existsSync(nodeModules)) return []; + const out = []; + for (const name of fs.readdirSync(nodeModules)) { + if (name.startsWith('.')) continue; + if (name.startsWith('@')) { + for (const scopedName of fs.readdirSync(path.join(nodeModules, name))) { + out.push(`${name}/${scopedName}`); + } + } else { + out.push(name); + } + } + return out; +} + +function buildDependencyAuditLight(options = {}) { + const root = path.resolve(options.root || ROOT); + const pkg = readJson(path.join(root, 'package.json'), {}); + const lock = readJson(path.join(root, 'package-lock.json'), {}); + const directDeps = Object.keys(pkg.dependencies || {}).sort(); + const devDeps = Object.keys(pkg.devDependencies || {}).sort(); + const lockPackages = lock.packages && typeof lock.packages === 'object' ? lock.packages : {}; + const versionByName = new Map(); + for (const [pkgPath, meta] of Object.entries(lockPackages)) { + if (!pkgPath.startsWith('node_modules/')) continue; + const name = pkgPath.replace(/^node_modules\//, ''); + if (!name || name.includes('/node_modules/')) continue; + const version = String(meta && meta.version ? meta.version : '').trim(); + if (!version) continue; + if (!versionByName.has(name)) versionByName.set(name, new Set()); + versionByName.get(name).add(version); + } + const duplicateVersions = [...versionByName.entries()] + .filter(([, versions]) => versions.size > 1) + .map(([name, versions]) => ({ name, versions: [...versions].sort() })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const sizes = topLevelNodeModules(root) + .map((name) => ({ + name, + sizeBytes: dirSizeBytes(path.join(root, 'node_modules', name)), + })) + .sort((a, b) => b.sizeBytes - a.sizeBytes) + .slice(0, 20); + + return { + ok: true, + generatedAt: new Date().toISOString(), + directDependencyCount: directDeps.length, + devDependencyCount: devDeps.length, + lockPackageCount: Object.keys(lockPackages).length, + directDependencies: directDeps, + devDependencies: devDeps, + duplicateVersions, + largestInstalledPackages: sizes, + }; +} + +function main() { + const json = process.argv.includes('--json'); + const result = buildDependencyAuditLight(); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log('dependency audit light'); + console.log(`direct deps: ${result.directDependencyCount}`); + console.log(`dev deps: ${result.devDependencyCount}`); + console.log(`lock packages: ${result.lockPackageCount}`); + console.log(`duplicate versions: ${result.duplicateVersions.length}`); + console.log('largest installed packages:'); + for (const row of result.largestInstalledPackages.slice(0, 10)) { + console.log(`- ${row.name}: ${row.sizeBytes} bytes`); + } + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildDependencyAuditLight, +}; diff --git a/scripts/lib/bot_app_entry.js b/scripts/lib/bot_app_entry.js index c4fb6f7..6298237 100755 --- a/scripts/lib/bot_app_entry.js +++ b/scripts/lib/bot_app_entry.js @@ -5,7 +5,7 @@ const path = require('path'); const KNOWN_ROUTES = new Set([ 'auto', 'work', 'inspect', 'deploy', 'project', 'ops', 'word', 'news', 'prompt', 'finance', 'todo', 'routine', - 'workout', 'media', 'place', 'status', 'link', 'memo', 'report', 'codex', + 'workout', 'media', 'place', 'job', 'status', 'link', 'memo', 'report', 'codex', ]); function parseRoutePayload(argv, defaultRoute) { diff --git a/scripts/lib/bridge_auto_routes.js b/scripts/lib/bridge_auto_routes.js index d539d92..ed2a477 100644 --- a/scripts/lib/bridge_auto_routes.js +++ b/scripts/lib/bridge_auto_routes.js @@ -1,6 +1,6 @@ const { resolveOauthRouteModelPolicy } = require('./oauth_model_policy'); -const PERSONAL_ROUTES = new Set(['finance', 'todo', 'routine', 'workout', 'media', 'place']); +const PERSONAL_ROUTES = new Set(['finance', 'todo', 'routine', 'workout', 'media', 'place', 'job']); async function buildStructuredRouteResponse(route, payload, options = {}, deps = {}) { const parsed = deps.parseStructuredCommand(route, payload); diff --git a/scripts/lib/bridge_direct_commands.js b/scripts/lib/bridge_direct_commands.js index 77c42b0..37b19f2 100644 --- a/scripts/lib/bridge_direct_commands.js +++ b/scripts/lib/bridge_direct_commands.js @@ -194,7 +194,8 @@ async function handleDirectBridgeCommand(context = {}, deps = {}) { case 'routine': case 'workout': case 'media': - case 'place': { + case 'place': + case 'job': { const out = await handlePersonalRoute(normalizedCommand, fullText, { source: 'telegram', }); diff --git a/scripts/lib/bridge_nl_inference.js b/scripts/lib/bridge_nl_inference.js index 90d6479..d8fbc2d 100644 --- a/scripts/lib/bridge_nl_inference.js +++ b/scripts/lib/bridge_nl_inference.js @@ -285,6 +285,28 @@ function inferWorkoutIntentPayload(text) { .trim() || raw; } +function inferJobIntentPayload(text) { + const raw = String(text || '').trim(); + if (!raw) return null; + + const hasExplicitJobLead = /^(지원처|지원|채용|job)\s*[::]?\s*/i.test(raw); + const hasJobKeyword = /(지원처|채용|구인|구직|이직|지원\s*파이프라인|job\s*pipeline|application|리크루터|recruiter|hiring\s*manager|면접|코딩\s*테스트|코테|오퍼|탈락|팔로업|follow.?up)/i.test(raw); + const hasJobAction = /(추가|등록|저장|메모|단계|변경|업데이트|다음\s*액션|다음액션|마감|목록|리스트|현황|검색|찾아|상세|주간\s*요약|주간요약|보여|알려|필요)/i.test(raw); + const hasCompanyField = /(회사명|회사|포지션|직무|링크|기술스택|fit_score|interest_score|pass_probability|priority)\s*[=:]/i.test(raw); + const hasStageChange = /\b(wishlist|applied|recruiter_contact|screening|coding_test|interview_1|interview_2|final_interview|offer|rejected|withdrawn|on_hold)\b/i.test(raw) + && /(현재\s*)?단계|stage|변경/i.test(raw); + const hasFollowupQuery = /(이번\s*주|오늘).*(팔로업|follow.?up|액션|마감).*(회사|지원|채용)?/i.test(raw); + const hasNaturalProcessUpdate = /(?:서류\s*(?:통과|합격|대기|검토)|(?:1차|2차|최종|캐주얼)\s*(?:면접|면담)|면접\s*(?:있|대기|예정|일정|일|시간)|면담\s*(?:있|대기|예정|일정|일|시간)|코딩\s*테스트|코테|오퍼|탈락|불합격|보류|철회|비즈리치)/i.test(raw); + + if (!hasExplicitJobLead && !hasCompanyField && !hasStageChange && !hasFollowupQuery && !hasNaturalProcessUpdate && !(hasJobKeyword && hasJobAction)) { + return null; + } + + return raw + .replace(/^(지원처|지원|채용|job)\s*(?:으로|로|에|를|은|는)?\s*[::]?\s*/i, '') + .trim() || raw; +} + function inferBrowserIntentPayload(text) { const raw = String(text || '').trim(); if (!raw) return null; @@ -637,6 +659,7 @@ function inferNaturalLanguageRoute(text, options = {}, deps = {}) { const inferTodo = typeof deps.inferTodoIntentPayload === 'function' ? deps.inferTodoIntentPayload : inferTodoIntentPayload; const inferRoutine = typeof deps.inferRoutineIntentPayload === 'function' ? deps.inferRoutineIntentPayload : inferRoutineIntentPayload; const inferWorkout = typeof deps.inferWorkoutIntentPayload === 'function' ? deps.inferWorkoutIntentPayload : inferWorkoutIntentPayload; + const inferJob = typeof deps.inferJobIntentPayload === 'function' ? deps.inferJobIntentPayload : inferJobIntentPayload; const inferBrowser = typeof deps.inferBrowserIntentPayload === 'function' ? deps.inferBrowserIntentPayload : inferBrowserIntentPayload; const inferSchedule = typeof deps.inferScheduleIntentPayload === 'function' ? deps.inferScheduleIntentPayload : inferScheduleIntentPayload; const inferGogLookup = typeof deps.inferGogLookupIntentPayload === 'function' ? deps.inferGogLookupIntentPayload : inferGogLookupIntentPayload; @@ -659,6 +682,12 @@ function inferNaturalLanguageRoute(text, options = {}, deps = {}) { return { route: 'memo', payload, inferred: true, inferredBy: 'natural-language:memo' }; } } + if (routing.inferJob) { + const payload = inferJob(normalized); + if (payload != null) { + return { route: 'job', payload, inferred: true, inferredBy: 'natural-language:job' }; + } + } if (routing.inferFinance) { const payload = inferFinance(normalized); if (payload != null) { @@ -752,6 +781,7 @@ module.exports = { inferTodoIntentPayload, inferRoutineIntentPayload, inferWorkoutIntentPayload, + inferJobIntentPayload, inferBrowserIntentPayload, inferScheduleIntentPayload, inferGogLookupIntentPayload, diff --git a/scripts/lib/bridge_no_prefix_reply.js b/scripts/lib/bridge_no_prefix_reply.js index 1f6ebe5..259a917 100644 --- a/scripts/lib/bridge_no_prefix_reply.js +++ b/scripts/lib/bridge_no_prefix_reply.js @@ -7,6 +7,7 @@ const NO_PREFIX_GUIDE_LINES = Object.freeze([ '- rust wasm 게임 템플릿 만들어줘', '- 메모장에 오늘 회고 저장해줘', '- 점심 1200엔 가계에 기록해줘', + '- 지원처 추가 회사명=Acme 포지션=Backend Engineer', '- 데일리 봇 상태 알려줘', '- 웹앱 링크 보내줘', '', diff --git a/scripts/lib/bridge_route_dispatch.js b/scripts/lib/bridge_route_dispatch.js index d6f8c1b..70140c2 100644 --- a/scripts/lib/bridge_route_dispatch.js +++ b/scripts/lib/bridge_route_dispatch.js @@ -36,6 +36,7 @@ function buildRoutingRules(prefixes = {}) { { route: 'workout', prefixes: listPrefixes(prefixes.workout || '운동:') }, { route: 'media', prefixes: listPrefixes(prefixes.media || '콘텐츠:') }, { route: 'place', prefixes: listPrefixes(prefixes.place || '식당:').concat(listPrefixes(prefixes.restaurant || '맛집:')) }, + { route: 'job', prefixes: listPrefixes(prefixes.job || '지원:').concat(listPrefixes(prefixes.jobPipeline || '지원처:'), listPrefixes(prefixes.recruiting || '채용:'), listPrefixes(prefixes.jobEn || 'job:')) }, { route: 'news', prefixes: listPrefixes(prefixes.news || '소식:') }, { route: 'report', prefixes: listPrefixes(prefixes.report || '리포트:').concat(listPrefixes(prefixes.summary || '요약:')) }, { route: 'work', prefixes: listPrefixes(prefixes.work || '작업:').concat(listPrefixes(prefixes.do || '실행:')) }, diff --git a/scripts/lib/bridge_runtime_policy.js b/scripts/lib/bridge_runtime_policy.js index 16678a2..e14a5d9 100644 --- a/scripts/lib/bridge_runtime_policy.js +++ b/scripts/lib/bridge_runtime_policy.js @@ -149,6 +149,7 @@ function normalizeNaturalLanguageRoutingConfig(rawConfig, env = process.env, dep let inferTodo = pickBool('inferTodo', Boolean(defaults.inferTodo)); let inferRoutine = pickBool('inferRoutine', Boolean(defaults.inferRoutine)); let inferWorkout = pickBool('inferWorkout', Boolean(defaults.inferWorkout)); + let inferJob = pickBool('inferJob', Boolean(defaults.inferJob)); let inferBrowser = pickBool('inferBrowser', Boolean(defaults.inferBrowser)); let inferSchedule = pickBool('inferSchedule', Boolean(defaults.inferSchedule)); let inferStatus = pickBool('inferStatus', Boolean(defaults.inferStatus)); @@ -190,6 +191,10 @@ function normalizeNaturalLanguageRoutingConfig(rawConfig, env = process.env, dep const parsed = parseBool(env.BRIDGE_NL_INFER_WORKOUT); if (parsed != null) inferWorkout = parsed; } + if (Object.prototype.hasOwnProperty.call(env, 'BRIDGE_NL_INFER_JOB')) { + const parsed = parseBool(env.BRIDGE_NL_INFER_JOB); + if (parsed != null) inferJob = parsed; + } if (Object.prototype.hasOwnProperty.call(env, 'BRIDGE_NL_INFER_BROWSER')) { const parsed = parseBool(env.BRIDGE_NL_INFER_BROWSER); if (parsed != null) inferBrowser = parsed; @@ -232,6 +237,7 @@ function normalizeNaturalLanguageRoutingConfig(rawConfig, env = process.env, dep inferTodo, inferRoutine, inferWorkout, + inferJob, inferBrowser, inferSchedule, inferStatus, diff --git a/scripts/oai_api_router.js b/scripts/oai_api_router.js index e4d54f9..f218015 100644 --- a/scripts/oai_api_router.js +++ b/scripts/oai_api_router.js @@ -35,6 +35,7 @@ const DEFAULT_POLICY = { workout: 'local-only', media: 'local-only', place: 'local-only', + job: 'local-only', status: 'local-only', link: 'local-only', ops: 'local-only', @@ -58,6 +59,7 @@ const ROUTE_PREFIXES = [ { route: 'workout', prefixes: ['운동:'] }, { route: 'media', prefixes: ['콘텐츠:'] }, { route: 'place', prefixes: ['식당:', '맛집:'] }, + { route: 'job', prefixes: ['지원:', '지원처:', '채용:', 'job:'] }, { route: 'report', prefixes: ['리포트:', '요약:'] }, { route: 'work', prefixes: ['작업:', '실행:'] }, { route: 'inspect', prefixes: ['점검:', '검토:'] }, @@ -300,6 +302,12 @@ function inferRouteFromCommand(commandText, options = {}) { prefixes: [configPrefixes.place, configPrefixes.restaurant].filter(Boolean), }; } + if (rule.route === 'job') { + return { + route: rule.route, + prefixes: [configPrefixes.job, configPrefixes.jobPipeline, configPrefixes.recruiting, configPrefixes.jobEn].filter(Boolean), + }; + } if (rule.route === 'status') { return { route: rule.route, diff --git a/scripts/openclaw_config_secrets.js b/scripts/openclaw_config_secrets.js index 15291f7..c96f7fd 100644 --- a/scripts/openclaw_config_secrets.js +++ b/scripts/openclaw_config_secrets.js @@ -206,6 +206,7 @@ function parseBoolean(input, fallback = false) { } function execPolicyFor(profile) { + if (profile === 'daily') return { ask: 'on-miss' }; // Backup daily bot runs fully inside container; avoid node-pairing requirement. if (profile === 'daily_bak') return { host: 'sandbox', ask: 'off' }; return null; diff --git a/scripts/ops_alerts_compact.js b/scripts/ops_alerts_compact.js new file mode 100644 index 0000000..4a71747 --- /dev/null +++ b/scripts/ops_alerts_compact.js @@ -0,0 +1,291 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const ROOT = process.env.OPS_WORKSPACE_ROOT + ? path.resolve(String(process.env.OPS_WORKSPACE_ROOT)) + : path.resolve(__dirname, '..'); +const DEFAULT_SENT_DIR = path.join(ROOT, 'ops', 'alerts', 'sent'); +const DEFAULT_SUMMARY_DIR = path.join(ROOT, 'ops', 'alerts', 'summaries'); + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_) { + return fallback; + } +} + +function writeJsonAtomic(filePath, payload) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + fs.writeFileSync(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + fs.renameSync(tmpPath, filePath); +} + +function safeLstat(filePath) { + try { + return fs.lstatSync(filePath); + } catch (_) { + return null; + } +} + +function isInsideRoot(root, filePath) { + const rel = path.relative(root, filePath); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function sanitizeToken(value) { + return String(value || 'unknown') + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') || 'unknown'; +} + +function alertDate(alert, fallbackName = '') { + const raw = String((alert && alert.created_at) || fallbackName || '').slice(0, 10); + return /^\d{4}-\d{2}-\d{2}$/.test(raw) ? raw : 'unknown-date'; +} + +function collectAlertCompactPlan(options = {}) { + const root = path.resolve(options.root || ROOT); + const sentDir = path.resolve(options.sentDir || path.join(root, 'ops', 'alerts', 'sent')); + const summaryDir = path.resolve(options.summaryDir || path.join(root, 'ops', 'alerts', 'summaries')); + const includeSingle = options.includeSingle === true; + const skipped = []; + const groups = new Map(); + + if (!isInsideRoot(root, sentDir) || !isInsideRoot(root, summaryDir)) { + throw new Error('alert compact paths must stay inside the workspace root'); + } + + const sentStat = safeLstat(sentDir); + if (!sentStat || !sentStat.isDirectory()) { + return { + ok: true, + root, + sentDir, + summaryDir, + groupCount: 0, + fileCount: 0, + groups: [], + skipped: [{ path: path.relative(root, sentDir) || '.', reason: 'missing_sent_dir' }], + }; + } + + for (const name of fs.readdirSync(sentDir).sort()) { + if (!name.endsWith('.json')) continue; + const filePath = path.join(sentDir, name); + const stat = safeLstat(filePath); + if (!stat) { + skipped.push({ path: path.relative(root, filePath), reason: 'missing' }); + continue; + } + if (stat.isSymbolicLink()) { + skipped.push({ path: path.relative(root, filePath), reason: 'symlink' }); + continue; + } + if (!stat.isFile()) continue; + const alert = readJson(filePath, null); + if (!alert || typeof alert !== 'object') { + skipped.push({ path: path.relative(root, filePath), reason: 'invalid_json' }); + continue; + } + const date = alertDate(alert, name); + const issueId = sanitizeToken(alert.issue_id || 'unknown_issue'); + const severity = sanitizeToken(alert.severity || 'unknown_severity'); + const key = `${date}__${issueId}`; + if (!groups.has(key)) { + groups.set(key, { + date, + issueId, + severity, + files: [], + alerts: [], + }); + } + const group = groups.get(key); + group.files.push(filePath); + group.alerts.push(alert); + } + + const compactGroups = []; + for (const group of groups.values()) { + if (!includeSingle && group.files.length < 2) continue; + const createdTimes = group.alerts + .map((alert) => String(alert.created_at || '').trim()) + .filter(Boolean) + .sort(); + const summaryPath = path.join( + summaryDir, + `${sanitizeToken(group.date)}_${sanitizeToken(group.issueId)}.summary.json`, + ); + compactGroups.push({ + key: `${group.date}:${group.issueId}`, + date: group.date, + issueId: group.issueId, + severity: group.severity, + count: group.files.length, + summaryPath, + sourceFiles: group.files, + firstCreatedAt: createdTimes[0] || null, + lastCreatedAt: createdTimes[createdTimes.length - 1] || null, + }); + } + compactGroups.sort((a, b) => a.key.localeCompare(b.key)); + + return { + ok: true, + root, + sentDir, + summaryDir, + groupCount: compactGroups.length, + fileCount: compactGroups.reduce((sum, group) => sum + group.count, 0), + groups: compactGroups, + skipped, + }; +} + +function buildSummary(group) { + return { + schema_version: '1.0', + generated_at: new Date().toISOString(), + date: group.date, + issue_id: group.issueId, + severity: group.severity, + alert_count: group.count, + first_created_at: group.firstCreatedAt, + last_created_at: group.lastCreatedAt, + source_files: group.sourceFiles.map((filePath) => path.basename(filePath)), + }; +} + +function runAlertCompact(options = {}) { + const apply = options.apply === true; + const plan = collectAlertCompactPlan(options); + const written = []; + const deleted = []; + const errors = []; + + if (apply) { + for (const group of plan.groups) { + try { + writeJsonAtomic(group.summaryPath, buildSummary(group)); + written.push(path.relative(plan.root, group.summaryPath)); + for (const filePath of group.sourceFiles) { + const stat = safeLstat(filePath); + if (!stat || stat.isSymbolicLink() || !stat.isFile() || !isInsideRoot(plan.root, filePath)) { + errors.push({ path: path.relative(plan.root, filePath), reason: 'unsafe_source' }); + continue; + } + fs.rmSync(filePath, { force: true }); + deleted.push(path.relative(plan.root, filePath)); + } + } catch (error) { + errors.push({ + group: group.key, + reason: String(error && error.message ? error.message : error), + }); + } + } + } + + return { + ...plan, + apply, + written, + deleted, + errorCount: errors.length, + errors, + ok: errors.length === 0, + }; +} + +function compactResult(result, limit = 20) { + const n = Math.max(0, Math.floor(Number(limit) || 20)); + return { + ok: result.ok, + apply: result.apply, + root: result.root, + groupCount: result.groupCount, + fileCount: result.fileCount, + writtenCount: Array.isArray(result.written) ? result.written.length : 0, + deletedCount: Array.isArray(result.deleted) ? result.deleted.length : 0, + skippedCount: Array.isArray(result.skipped) ? result.skipped.length : 0, + errorCount: result.errorCount || 0, + groupSample: result.groups.slice(0, n).map((group) => ({ + key: group.key, + count: group.count, + firstCreatedAt: group.firstCreatedAt, + lastCreatedAt: group.lastCreatedAt, + })), + hasMoreGroups: result.groups.length > n, + skippedSample: result.skipped.slice(0, n), + errors: result.errors.slice(0, n), + }; +} + +function parseArgs(argv) { + const out = { + apply: false, + json: false, + full: false, + includeSingle: false, + limit: 20, + root: ROOT, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--apply') out.apply = true; + else if (arg === '--json') out.json = true; + else if (arg === '--full') out.full = true; + else if (arg === '--include-single') out.includeSingle = true; + else if (arg === '--limit') { + i += 1; + out.limit = Number(argv[i]); + } else if (arg.startsWith('--limit=')) { + out.limit = Number(arg.slice('--limit='.length)); + } else if (arg === '--root') { + i += 1; + out.root = path.resolve(argv[i]); + } else if (arg.startsWith('--root=')) { + out.root = path.resolve(arg.slice('--root='.length)); + } + } + return out; +} + +function printText(result) { + console.log(`ops alerts compact (${result.apply ? 'apply' : 'dry-run'})`); + console.log(`groups: ${result.groupCount}`); + console.log(`files: ${result.fileCount}`); + console.log(`written: ${result.written.length}`); + console.log(`deleted: ${result.deleted.length}`); + for (const group of result.groups.slice(0, 20)) { + console.log(`- ${group.key}: ${group.count} files`); + } + if (result.groups.length > 20) { + console.log(`... ${result.groups.length - 20} more`); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const result = runAlertCompact(args); + if (args.json) { + console.log(JSON.stringify(args.full ? result : compactResult(result, args.limit), null, 2)); + } else { + printText(result); + } + if (!result.ok) process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + collectAlertCompactPlan, + compactResult, + runAlertCompact, +}; diff --git a/scripts/ops_approvals_gc.js b/scripts/ops_approvals_gc.js new file mode 100644 index 0000000..d4cc4e6 --- /dev/null +++ b/scripts/ops_approvals_gc.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +const opsApprovalStore = require('./ops_approval_store'); + +try { + opsApprovalStore.ensureLayout(); + const result = opsApprovalStore.expirePendingTokens(); + console.log(JSON.stringify({ + ok: true, + ...result, + }, null, 2)); +} catch (error) { + console.error(JSON.stringify({ + ok: false, + error: String(error && error.message ? error.message : error), + }, null, 2)); + process.exit(1); +} diff --git a/scripts/ops_command_queue.js b/scripts/ops_command_queue.js index 156cf17..d52e19e 100644 --- a/scripts/ops_command_queue.js +++ b/scripts/ops_command_queue.js @@ -1,8 +1,12 @@ const fs = require('fs'); const path = require('path'); -const ROOT = path.join(__dirname, '..'); -const OPS_COMMANDS_ROOT = path.join(ROOT, 'ops', 'commands'); +const ROOT = process.env.OPS_WORKSPACE_ROOT + ? path.resolve(String(process.env.OPS_WORKSPACE_ROOT)) + : path.join(__dirname, '..'); +const OPS_COMMANDS_ROOT = process.env.OPS_COMMANDS_ROOT + ? path.resolve(String(process.env.OPS_COMMANDS_ROOT)) + : path.join(ROOT, 'ops', 'commands'); const OUTBOX_DIR = path.join(OPS_COMMANDS_ROOT, 'outbox'); const STATE_DIR = path.join(OPS_COMMANDS_ROOT, 'state'); const PROCESSING_DIR = path.join(STATE_DIR, 'processing'); diff --git a/scripts/ops_dashboard.js b/scripts/ops_dashboard.js new file mode 100644 index 0000000..f843321 --- /dev/null +++ b/scripts/ops_dashboard.js @@ -0,0 +1,315 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const ROOT = path.resolve(__dirname, '..'); + +const DOCKER_TARGETS = Object.freeze([ + { id: 'daily', container: 'moltbot-daily' }, + { id: 'dev', container: 'moltbot-dev' }, + { id: 'anki', container: 'moltbot-anki' }, + { id: 'research', container: 'moltbot-research' }, +]); + +function safeStat(filePath) { + try { + return fs.lstatSync(filePath); + } catch (_) { + return null; + } +} + +function readJson(filePath, fallback = null) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (_) { + return fallback; + } +} + +function readJsonl(filePath, limit = 500) { + try { + const lines = fs.readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(-limit); + return lines.map((line) => { + try { + return JSON.parse(line); + } catch (_) { + return null; + } + }).filter(Boolean); + } catch (_) { + return []; + } +} + +function countFiles(dirPath, predicate = () => true) { + const stat = safeStat(dirPath); + if (!stat || !stat.isDirectory()) return 0; + try { + return fs.readdirSync(dirPath) + .filter((name) => predicate(path.join(dirPath, name), name)) + .length; + } catch (_) { + return 0; + } +} + +function dirSizeBytes(dirPath) { + const stat = safeStat(dirPath); + if (!stat) return 0; + if (stat.isSymbolicLink()) return 0; + if (stat.isFile()) return stat.size; + if (!stat.isDirectory()) return 0; + let total = 0; + let entries = []; + try { + entries = fs.readdirSync(dirPath); + } catch (_) { + return 0; + } + for (const entry of entries) { + total += dirSizeBytes(path.join(dirPath, entry)); + } + return total; +} + +function summarizeStateFiles() { + const relPaths = [ + 'ops/state/state.json', + 'ops/state/issues.json', + 'ops/state/leader_snapshot_latest.json', + 'logs/nightly_autopilot_latest.json', + 'logs/cron_guard_latest.json', + 'logs/notion_sync_dashboard_latest.json', + 'logs/model_cost_latency_dashboard_latest.json', + ]; + return relPaths.map((relPath) => { + const filePath = path.join(ROOT, relPath); + const stat = safeStat(filePath); + return { + path: relPath, + exists: Boolean(stat && stat.isFile()), + sizeBytes: stat && stat.isFile() ? stat.size : 0, + mtime: stat && stat.isFile() ? stat.mtime.toISOString() : null, + }; + }); +} + +function dockerStatus(container) { + const res = spawnSync('docker', [ + 'ps', + '-a', + '--filter', + `name=^/${container}$`, + '--format', + '{{json .}}', + ], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (res.error || res.status !== 0) { + return { + dockerOk: false, + status: 'unknown', + raw: '', + }; + } + const line = String(res.stdout || '').split(/\r?\n/).map((v) => v.trim()).find(Boolean); + if (!line) { + return { + dockerOk: true, + status: 'missing', + raw: '', + }; + } + const parsed = readJsonFromString(line); + return { + dockerOk: true, + status: String((parsed && parsed.Status) || 'unknown'), + raw: parsed || line, + }; +} + +function readJsonFromString(value) { + try { + return JSON.parse(value); + } catch (_) { + return null; + } +} + +function assessOpsDashboardHealth(payload) { + const reasons = []; + let score = 0; + + function bump(nextScore, reason) { + score = Math.max(score, nextScore); + if (reason) reasons.push(reason); + } + + const queues = payload.queues || {}; + if (Number(queues.outbox || 0) > 0 || Number(queues.pending || 0) > 0) { + bump(1, `운영 큐 대기: outbox=${queues.outbox || 0}, pending=${queues.pending || 0}`); + } + if (Number(queues.pendingApprovals || 0) > 0) { + bump(1, `승인 대기 ${queues.pendingApprovals}건`); + } + + const recentFailures = Array.isArray(payload.recentFailures) ? payload.recentFailures : []; + if (recentFailures.length >= 5) { + bump(2, `최근 실패 ${recentFailures.length}건`); + } else if (recentFailures.length > 0) { + bump(1, `최근 실패 ${recentFailures.length}건`); + } + + const docker = Array.isArray(payload.docker) ? payload.docker : []; + const unhealthyDocker = docker.filter((row) => { + const status = String(row && row.status ? row.status : '').trim(); + return !row || row.dockerOk === false || !/^up\b/i.test(status); + }); + if (unhealthyDocker.length > 0) { + bump(2, `Docker 상태 확인 필요: ${unhealthyDocker.map((row) => row.id || row.container || 'unknown').join(', ')}`); + } + + const stateFiles = Array.isArray(payload.stateFiles) ? payload.stateFiles : []; + const requiredStateFiles = new Set(['ops/state/state.json', 'ops/state/issues.json']); + const missingRequiredState = stateFiles.filter((row) => requiredStateFiles.has(row.path) && !row.exists); + if (missingRequiredState.length > 0) { + bump(1, `주요 상태 파일 없음: ${missingRequiredState.map((row) => row.path).join(', ')}`); + } + + const artifactSizes = payload.artifactSizes || {}; + const totalArtifacts = Number(artifactSizes.logsBytes || 0) + + Number(artifactSizes.reportsBytes || 0) + + Number(artifactSizes.opsBytes || 0); + if (totalArtifacts > 500 * 1024 * 1024) { + bump(1, `런타임 산출물 500MB 초과: ${totalArtifacts} bytes`); + } + + const level = score >= 2 ? 'danger' : score === 1 ? 'warning' : 'ok'; + const label = level === 'danger' ? '위험' : level === 'warning' ? '주의' : '정상'; + return { + level, + label, + reasons, + }; +} + +function compactOpsDashboard(payload) { + return { + ...payload, + docker: Array.isArray(payload.docker) + ? payload.docker.map((row) => ({ + id: row.id, + container: row.container, + dockerOk: row.dockerOk, + status: row.status, + })) + : [], + }; +} + +function buildOpsDashboard() { + const resultsPath = path.join(ROOT, 'ops', 'commands', 'results.jsonl'); + const results = readJsonl(resultsPath, 1000); + const failures = results + .filter((row) => row && row.ok === false) + .slice(-10) + .reverse() + .map((row) => ({ + requestId: row.request_id || null, + finishedAt: row.finished_at || null, + commandKind: row.command_kind || null, + action: row.action || row.intent_action || null, + errorCode: row.error_code || row.errorCode || null, + })); + + const commandsRoot = path.join(ROOT, 'ops', 'commands'); + const outboxDir = path.join(commandsRoot, 'outbox'); + const pendingDir = path.join(commandsRoot, 'state', 'pending'); + const approvalsPath = path.join(ROOT, 'data', 'state', 'pending_approvals.json'); + const pendingApprovals = readJson(approvalsPath, {}); + const approvalCount = pendingApprovals && typeof pendingApprovals === 'object' + ? Object.keys(pendingApprovals).length + : 0; + + const docker = DOCKER_TARGETS.map((target) => ({ + id: target.id, + container: target.container, + ...dockerStatus(target.container), + })); + + const payload = { + ok: true, + generatedAt: new Date().toISOString(), + recentFailures: failures, + queues: { + outbox: countFiles(outboxDir, (filePath, name) => name.endsWith('.json')), + pending: countFiles(pendingDir), + pendingApprovals: approvalCount, + }, + stateFiles: summarizeStateFiles(), + artifactSizes: { + logsBytes: dirSizeBytes(path.join(ROOT, 'logs')), + reportsBytes: dirSizeBytes(path.join(ROOT, 'reports')), + opsBytes: dirSizeBytes(path.join(ROOT, 'ops')), + }, + docker, + }; + payload.health = assessOpsDashboardHealth(payload); + return payload; +} + +function parseArgs(argv) { + return { + json: argv.includes('--json'), + full: argv.includes('--full'), + }; +} + +function printText(payload) { + console.log('ops dashboard'); + console.log(`generated: ${payload.generatedAt}`); + console.log(`health: ${payload.health.label} (${payload.health.level})`); + if (payload.health.reasons.length > 0) { + console.log('health reasons:'); + for (const reason of payload.health.reasons.slice(0, 5)) { + console.log(`- ${reason}`); + } + } + console.log(`queue outbox: ${payload.queues.outbox}`); + console.log(`queue pending: ${payload.queues.pending}`); + console.log(`pending approvals: ${payload.queues.pendingApprovals}`); + console.log(`recent failures: ${payload.recentFailures.length}`); + console.log(`artifact sizes: logs=${payload.artifactSizes.logsBytes} reports=${payload.artifactSizes.reportsBytes} ops=${payload.artifactSizes.opsBytes}`); + console.log('docker:'); + for (const row of payload.docker) { + console.log(`- ${row.id}: ${row.status}`); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const payload = buildOpsDashboard(); + if (args.json) { + console.log(JSON.stringify(args.full ? payload : compactOpsDashboard(payload), null, 2)); + } else { + printText(payload); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + assessOpsDashboardHealth, + buildOpsDashboard, + compactOpsDashboard, +}; diff --git a/scripts/ops_runtime_gc.js b/scripts/ops_runtime_gc.js new file mode 100644 index 0000000..0e0ba38 --- /dev/null +++ b/scripts/ops_runtime_gc.js @@ -0,0 +1,289 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +const DEFAULT_ROOT = process.env.OPS_GC_ROOT + ? path.resolve(String(process.env.OPS_GC_ROOT)) + : path.resolve(__dirname, '..'); + +const DEFAULT_TARGETS = Object.freeze([ + 'logs', + 'reports', + 'ops/alerts/sent', + 'ops/state/briefing_locks', + 'ops/commands/state/completed', + 'ops/commands/state/consumed', +]); +const KEEP_FILE_NAMES = new Set(['.gitkeep', '.gitignore']); + +function toPosix(relPath) { + return String(relPath || '').split(path.sep).join('/'); +} + +function toRel(root, filePath) { + const rel = path.relative(root, filePath); + return toPosix(rel || '.'); +} + +function isInsideRoot(root, filePath) { + const rel = path.relative(root, filePath); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function safeLstat(filePath) { + try { + return fs.lstatSync(filePath); + } catch (_) { + return null; + } +} + +function collectRuntimeGcPlan(options = {}) { + const root = path.resolve(options.root || DEFAULT_ROOT); + const targets = Array.isArray(options.targets) && options.targets.length > 0 + ? options.targets + : DEFAULT_TARGETS; + const days = Number.isFinite(Number(options.days)) ? Number(options.days) : 30; + const cutoffMs = Date.now() - (Math.max(0, days) * 24 * 60 * 60 * 1000); + const candidates = []; + const skipped = []; + + function skip(filePath, reason) { + skipped.push({ + path: toRel(root, filePath), + reason, + }); + } + + function visit(filePath) { + const absolutePath = path.resolve(filePath); + if (!isInsideRoot(root, absolutePath)) { + skip(absolutePath, 'outside_root'); + return; + } + const stat = safeLstat(absolutePath); + if (!stat) { + skip(absolutePath, 'missing'); + return; + } + if (stat.isSymbolicLink()) { + skip(absolutePath, 'symlink'); + return; + } + if (stat.isDirectory()) { + let entries = []; + try { + entries = fs.readdirSync(absolutePath); + } catch (_) { + skip(absolutePath, 'unreadable'); + return; + } + for (const entry of entries) { + visit(path.join(absolutePath, entry)); + } + return; + } + if (!stat.isFile()) { + skip(absolutePath, 'not_file'); + return; + } + if (KEEP_FILE_NAMES.has(path.basename(absolutePath))) { + skip(absolutePath, 'keep_file'); + return; + } + if (stat.mtimeMs <= cutoffMs) { + candidates.push({ + path: toRel(root, absolutePath), + absolutePath, + sizeBytes: stat.size, + mtime: stat.mtime.toISOString(), + }); + } + } + + for (const target of targets) { + visit(path.resolve(root, String(target || ''))); + } + + candidates.sort((a, b) => a.path.localeCompare(b.path)); + skipped.sort((a, b) => a.path.localeCompare(b.path)); + return { + ok: true, + root, + days, + cutoff: new Date(cutoffMs).toISOString(), + targets: targets.map((target) => toPosix(String(target || ''))), + candidates, + skipped, + }; +} + +function runRuntimeGc(options = {}) { + const apply = options.apply === true; + const plan = collectRuntimeGcPlan(options); + const deleted = []; + const deleteErrors = []; + + if (apply) { + for (const candidate of plan.candidates) { + const absolutePath = path.resolve(candidate.absolutePath); + const stat = safeLstat(absolutePath); + if (!stat) continue; + if (!isInsideRoot(plan.root, absolutePath) || stat.isSymbolicLink() || !stat.isFile()) { + deleteErrors.push({ + path: candidate.path, + reason: 'unsafe_path', + }); + continue; + } + try { + fs.rmSync(absolutePath, { force: true }); + deleted.push(candidate.path); + } catch (error) { + deleteErrors.push({ + path: candidate.path, + reason: String(error && error.message ? error.message : error), + }); + } + } + } + + return { + ...plan, + apply, + deleted, + deletedCount: deleted.length, + deleteErrors, + ok: deleteErrors.length === 0, + }; +} + +function normalizeLimit(value, fallback = 50) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(0, Math.floor(parsed)); +} + +function compactCandidate(row) { + return { + path: row.path, + sizeBytes: row.sizeBytes, + mtime: row.mtime, + }; +} + +function compactRuntimeGcResult(result, options = {}) { + const limit = normalizeLimit(options.limit, 50); + const candidates = Array.isArray(result.candidates) ? result.candidates : []; + const skipped = Array.isArray(result.skipped) ? result.skipped : []; + const deleted = Array.isArray(result.deleted) ? result.deleted : []; + const deleteErrors = Array.isArray(result.deleteErrors) ? result.deleteErrors : []; + + return { + ok: result.ok, + root: result.root, + days: result.days, + cutoff: result.cutoff, + targets: result.targets, + apply: result.apply, + candidateCount: candidates.length, + skippedCount: skipped.length, + deletedCount: result.deletedCount || deleted.length, + deleteErrorCount: deleteErrors.length, + candidateSample: candidates.slice(0, limit).map(compactCandidate), + skippedSample: skipped.slice(0, limit), + deletedSample: deleted.slice(0, limit), + deleteErrors: deleteErrors.slice(0, limit), + hasMoreCandidates: candidates.length > limit, + hasMoreSkipped: skipped.length > limit, + hasMoreDeleted: deleted.length > limit, + hasMoreDeleteErrors: deleteErrors.length > limit, + }; +} + +function parseArgs(argv) { + const out = { + apply: false, + json: false, + full: false, + limit: 50, + days: 30, + targets: [], + root: DEFAULT_ROOT, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--apply') out.apply = true; + else if (arg === '--json') out.json = true; + else if (arg === '--full') out.full = true; + else if (arg === '--summary') out.full = false; + else if (arg === '--limit') { + i += 1; + out.limit = normalizeLimit(argv[i], out.limit); + } else if (arg.startsWith('--limit=')) { + out.limit = normalizeLimit(arg.slice('--limit='.length), out.limit); + } else if (arg === '--days') { + i += 1; + out.days = Number(argv[i]); + } else if (arg.startsWith('--days=')) { + out.days = Number(arg.slice('--days='.length)); + } else if (arg === '--target') { + i += 1; + out.targets.push(argv[i]); + } else if (arg.startsWith('--target=')) { + out.targets.push(arg.slice('--target='.length)); + } else if (arg === '--root') { + i += 1; + out.root = path.resolve(argv[i]); + } else if (arg.startsWith('--root=')) { + out.root = path.resolve(arg.slice('--root='.length)); + } + } + return out; +} + +function printText(result, options = {}) { + const limit = normalizeLimit(options.limit, 20); + const mode = result.apply ? 'apply' : 'dry-run'; + console.log(`ops runtime gc (${mode})`); + console.log(`root: ${result.root}`); + console.log(`older than: ${result.days} days`); + console.log(`candidates: ${result.candidates.length}`); + console.log(`deleted: ${result.deletedCount}`); + if (result.candidates.length > 0) { + console.log(`sample (limit ${limit}):`); + for (const row of result.candidates.slice(0, limit)) { + console.log(`- ${row.path} (${row.sizeBytes} bytes, ${row.mtime})`); + } + if (result.candidates.length > limit) { + console.log(`... ${result.candidates.length - limit} more. Use --json --full to print every candidate.`); + } + } + if (result.skipped.length > 0) { + console.log(`skipped: ${result.skipped.length}`); + } +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const result = runRuntimeGc(args); + if (args.json) { + const payload = args.full ? result : compactRuntimeGcResult(result, { limit: args.limit }); + console.log(JSON.stringify(payload, null, 2)); + } else { + printText(result, args); + } + if (!result.ok) process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + DEFAULT_TARGETS, + KEEP_FILE_NAMES, + collectRuntimeGcPlan, + compactRuntimeGcResult, + runRuntimeGc, +}; diff --git a/scripts/package_scripts_manifest.js b/scripts/package_scripts_manifest.js new file mode 100644 index 0000000..d0046bf --- /dev/null +++ b/scripts/package_scripts_manifest.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); +const { checkPackageScripts } = require('./check_package_scripts'); + +const ROOT = path.resolve(__dirname, '..'); + +function classifyScript(name, command) { + if (name.startsWith('test')) return 'test'; + if (name.startsWith('ops')) return 'ops'; + if (name.startsWith('runtime')) return 'runtime'; + if (name.startsWith('cron')) return 'cron'; + if (name.startsWith('check') || name.startsWith('fix')) return 'check'; + if (name.startsWith('anki')) return 'anki'; + if (name.startsWith('news')) return 'news'; + if (name.startsWith('notion')) return 'notion'; + if (/archive|legacy|old/i.test(command)) return 'review'; + return 'other'; +} + +function splitCommandRefs(command) { + return String(command || '') + .split(/\s*&&\s*/) + .map((part) => part.trim()) + .filter(Boolean); +} + +function buildPackageScriptsManifest(options = {}) { + const root = path.resolve(options.root || ROOT); + const packagePath = path.join(root, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); + const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}; + const check = checkPackageScripts({ root, packageJson: pkg }); + const entries = Object.entries(scripts).map(([name, command]) => { + const refs = splitCommandRefs(command); + return { + name, + category: classifyScript(name, command), + command, + stepCount: refs.length, + reviewCandidate: refs.length >= 6 || /archive|legacy|old/i.test(command), + }; + }); + const categories = {}; + for (const entry of entries) { + categories[entry.category] = (categories[entry.category] || 0) + 1; + } + return { + ok: check.ok, + generatedAt: new Date().toISOString(), + totalScripts: entries.length, + categories, + reviewCandidates: entries.filter((entry) => entry.reviewCandidate), + issues: check.issues, + scripts: entries, + }; +} + +function parseArgs(argv) { + return { + json: argv.includes('--json'), + write: argv.includes('--write'), + }; +} + +function printText(manifest) { + console.log('package scripts manifest'); + console.log(`total: ${manifest.totalScripts}`); + console.log(`ok: ${manifest.ok}`); + console.log('categories:'); + for (const [name, count] of Object.entries(manifest.categories).sort()) { + console.log(`- ${name}: ${count}`); + } + console.log(`review candidates: ${manifest.reviewCandidates.length}`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const manifest = buildPackageScriptsManifest(); + if (args.write) { + const outPath = path.join(ROOT, 'reports', 'package_scripts_manifest.json'); + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + } + if (args.json) { + console.log(JSON.stringify(manifest, null, 2)); + } else { + printText(manifest); + } + if (!manifest.ok) process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildPackageScriptsManifest, + classifyScript, +}; diff --git a/scripts/personal_job_pipeline.js b/scripts/personal_job_pipeline.js new file mode 100644 index 0000000..feec593 --- /dev/null +++ b/scripts/personal_job_pipeline.js @@ -0,0 +1,863 @@ +const personalStorage = require('./personal_storage'); +const jobs = require('./personal_job_pipeline_storage'); + +const STAGE_LABELS = Object.freeze({ + wishlist: '관심 목록', + applied: '지원 완료', + recruiter_contact: '채용 담당자 연락 중', + screening: '서류 전형', + coding_test: '코딩 테스트', + interview_1: '1차 면접', + interview_2: '2차 면접', + final_interview: '최종 면접', + offer: '오퍼', + rejected: '탈락', + withdrawn: '지원 철회', + on_hold: '보류', +}); + +const STATUS_LABELS = Object.freeze({ + active: '진행 중', + closed: '종료', + on_hold: '보류', +}); + +function normalize(text) { + return personalStorage.normalizeSpace(text); +} + +function stageLabel(stage) { + const key = String(stage || 'wishlist').trim() || 'wishlist'; + return STAGE_LABELS[key] || key; +} + +function statusLabel(status) { + const key = String(status || 'active').trim() || 'active'; + return STATUS_LABELS[key] || key; +} + +function formatTimelineSummary(summary) { + return String(summary || '').replace( + /단계 변경:\s*([a-z0-9_가-힣-]+)\s*->\s*([a-z0-9_가-힣-]+)/i, + (_, from, to) => `단계 변경: ${from === '-' ? '없음' : stageLabel(from)} -> ${stageLabel(to)}`, + ); +} + +function tokyoDate(now = null) { + const date = now ? new Date(now) : new Date(); + const fmt = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Tokyo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + return fmt.format(Number.isFinite(date.getTime()) ? date : new Date()); +} + +function addDays(dateText, days) { + const date = new Date(`${dateText}T00:00:00+09:00`); + date.setUTCDate(date.getUTCDate() + Number(days || 0)); + return tokyoDate(date); +} + +function stripLead(text) { + return normalize(text) + .replace(/^(지원처|지원|채용|job)\s*[::]?\s*/i, '') + .trim(); +} + +function parseKeyValueFields(text) { + const raw = String(text || ''); + const keyRe = /(회사명|회사|company|포지션|직무|role|position|title|링크|url|jd_url|스택|기술스택|tech_stack|위치|지역|location|근무형태|work_mode|단계|stage|면접일|interview_at|fit_score|fit|interest_score|interest|pass_probability|확률|priority|우선순위|다음액션|next_action|액션|마감|due|source|출처|담당자|리크루터|recruiter)\s*[=:]\s*/gi; + const matches = []; + let match; + while ((match = keyRe.exec(raw)) != null) { + matches.push({ + key: String(match[1] || '').trim().toLowerCase(), + valueStart: keyRe.lastIndex, + start: match.index, + }); + } + const out = {}; + for (let i = 0; i < matches.length; i += 1) { + const cur = matches[i]; + const next = matches[i + 1]; + const value = raw.slice(cur.valueStart, next ? next.start : raw.length).trim(); + if (value) out[cur.key] = value.replace(/^["']|["']$/g, '').trim(); + } + return out; +} + +function firstField(fields, keys = []) { + for (const key of keys) { + const value = fields[String(key).toLowerCase()]; + if (value != null && String(value).trim()) return String(value).trim(); + } + return ''; +} + +function parseScore(value) { + if (value == null || value === '') return null; + const n = Number(String(value).replace(/[^\d.]/g, '')); + return Number.isFinite(n) ? n : null; +} + +function formatTimeFromRaw(text) { + const raw = String(text || ''); + const colon = raw.match(/\b([01]?\d|2[0-3]):([0-5]\d)\b/); + if (colon) return `${String(Number(colon[1])).padStart(2, '0')}:${colon[2]}`; + const match = raw.match(/(오전|오후)?\s*(\d{1,2})\s*시(?:\s*(\d{1,2})\s*분?)?/); + if (!match) return ''; + const meridiem = String(match[1] || '').trim(); + let hour = Number(match[2]); + const minute = Number(match[3] || 0); + if (!Number.isFinite(hour) || hour < 0 || hour > 24) return ''; + if (!Number.isFinite(minute) || minute < 0 || minute > 59) return ''; + if (meridiem === '오후' && hour < 12) hour += 12; + if (meridiem === '오전' && hour === 12) hour = 0; + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +function appendTimeIfPresent(dateText, raw) { + const date = String(dateText || '').trim(); + if (!date) return ''; + const time = formatTimeFromRaw(raw); + return time ? `${date} ${time}` : date; +} + +function parseDateToken(text, options = {}) { + const raw = String(text || '').trim(); + if (!raw) return ''; + const iso = raw.match(/\b(20\d{2}-\d{2}-\d{2})\b/); + if (iso) return appendTimeIfPresent(iso[1], raw); + const compact = raw.match(/\b(20\d{2})(\d{2})(\d{2})\b/); + if (compact) return appendTimeIfPresent(`${compact[1]}-${compact[2]}-${compact[3]}`, raw); + const slash = raw.match(/\b(\d{1,2})[./](\d{1,2})\b/); + if (slash) { + const year = tokyoDate(options.now).slice(0, 4); + return appendTimeIfPresent(`${year}-${String(slash[1]).padStart(2, '0')}-${String(slash[2]).padStart(2, '0')}`, raw); + } + const korean = raw.match(/(?:(20\d{2})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일/); + if (korean) { + const year = korean[1] || tokyoDate(options.now).slice(0, 4); + return appendTimeIfPresent(`${year}-${String(korean[2]).padStart(2, '0')}-${String(korean[3]).padStart(2, '0')}`, raw); + } + const today = tokyoDate(options.now); + if (/오늘/.test(raw)) return appendTimeIfPresent(today, raw); + if (/내일/.test(raw)) return appendTimeIfPresent(addDays(today, 1), raw); + if (/모레/.test(raw)) return appendTimeIfPresent(addDays(today, 2), raw); + if (/다음\s*주|next\s*week/i.test(raw)) return appendTimeIfPresent(addDays(today, 7), raw); + if (/이번\s*주|this\s*week/i.test(raw)) return appendTimeIfPresent(addDays(today, 7), raw); + return ''; +} + +function parseTags(text) { + return (String(text || '').match(/#[^\s#]+/g) || []) + .map((v) => v.replace(/^#/, '').trim()) + .filter(Boolean); +} + +function parseAddCommand(raw) { + const fields = parseKeyValueFields(raw); + const companyName = firstField(fields, ['회사명', '회사', 'company']); + const title = firstField(fields, ['포지션', '직무', 'role', 'position', 'title']) || '미정 포지션'; + if (!companyName) return null; + + return { + action: 'add', + company: { + name: companyName, + website: firstField(fields, ['링크', 'url']).match(/^https?:\/\//i) ? firstField(fields, ['링크', 'url']) : '', + location: firstField(fields, ['위치', '지역', 'location']), + workMode: firstField(fields, ['근무형태', 'work_mode']), + }, + opportunity: { + title, + jdUrl: firstField(fields, ['jd_url']) || (firstField(fields, ['링크', 'url']).match(/^https?:\/\//i) ? firstField(fields, ['링크', 'url']) : ''), + techStack: firstField(fields, ['스택', '기술스택', 'tech_stack']), + location: firstField(fields, ['위치', '지역', 'location']), + source: firstField(fields, ['source', '출처']), + fitScore: parseScore(firstField(fields, ['fit_score', 'fit'])), + interestScore: parseScore(firstField(fields, ['interest_score', 'interest'])), + passProbability: parseScore(firstField(fields, ['pass_probability', '확률'])), + priority: parseScore(firstField(fields, ['priority', '우선순위'])), + }, + stage: jobs.normalizeStage(firstField(fields, ['단계', 'stage'])) || 'wishlist', + nextActionTitle: firstField(fields, ['면접일', 'interview_at']) + ? actionTitleForStage(jobs.normalizeStage(firstField(fields, ['단계', 'stage'])) || 'interview_1') + : '', + dueAt: parseDateToken(firstField(fields, ['면접일', 'interview_at']), {}), + }; +} + +function parseStageCommand(raw) { + const explicit = raw.match(/^(.+?)\s+(?:현재\s*)?단계\s*(?:를|을|은|는|=|:)?\s*([a-z0-9_가-힣]+)\s*(?:로|으로)?\s*(?:변경|업데이트|수정)?$/i); + if (explicit) { + return { + action: 'stage', + token: normalize(explicit[1]), + stage: normalize(explicit[2]).replace(/(?:으로|로)$/i, ''), + }; + } + const fields = parseKeyValueFields(raw); + const stage = jobs.normalizeStage(firstField(fields, ['단계', 'stage'])); + if (stage) { + const token = raw.slice(0, raw.search(/(단계|stage)\s*[=:]/i)).trim(); + if (token) return { action: 'stage', token, stage }; + } + return null; +} + +function parseNextActionCommand(raw, options = {}) { + const fields = parseKeyValueFields(raw); + const keyedTitle = firstField(fields, ['다음액션', 'next_action', '액션']); + if (keyedTitle) { + const token = raw.slice(0, raw.search(/(다음액션|next_action|액션)\s*[=:]/i)).trim(); + return { + action: 'next_action', + token: token || firstField(fields, ['회사', '회사명', 'company']), + title: keyedTitle, + dueAt: parseDateToken(firstField(fields, ['마감', 'due']) || raw, options), + priority: parseScore(firstField(fields, ['priority', '우선순위'])), + }; + } + const m = raw.match(/^(.+?)\s+(?:다음\s*액션|다음액션|next\s*action)\s*(?:=|:|은|는)?\s*(.+)$/i); + if (!m) return null; + return { + action: 'next_action', + token: normalize(m[1]), + title: normalize(m[2].replace(/(?:마감|due)\s*[=:]\s*\S+/i, '')), + dueAt: parseDateToken(m[2], options), + }; +} + +function parseNoteCommand(raw) { + const lead = raw.match(/^(?:리크루터\s*)?메모\s*(?:저장|추가)?\s+(\S+)\s+(.+)$/i); + if (lead) { + return { + action: 'note', + token: normalize(lead[1]), + content: normalize(lead[2]), + eventType: /리크루터/i.test(raw) ? 'recruiter_note' : 'note', + }; + } + const tail = raw.match(/^(.+?)\s+메모\s*(?:저장|추가)?\s+(.+)$/i); + if (tail) { + return { + action: 'note', + token: normalize(tail[1]), + content: normalize(tail[2]), + eventType: 'note', + }; + } + return null; +} + +function parseContactCommand(raw) { + const m = raw.match(/^(?:연락처|컨택트|담당자)\s*(?:추가|저장)?\s+(\S+)\s+(.+)$/i); + if (!m) return null; + const body = normalize(m[2]); + const fields = parseKeyValueFields(body); + return { + action: 'contact', + token: normalize(m[1]), + name: firstField(fields, ['담당자', '리크루터', 'recruiter']) || body.split(/\s+/)[0], + role: /hiring|채용|매니저|manager/i.test(body) ? 'hiring_manager' : 'recruiter', + channel: /linkedin|링크드인/i.test(body) ? 'linkedin' : '', + contactUrlOrEmail: (body.match(/https?:\/\/\S+|[^\s@]+@[^\s@]+\.[^\s@]+/i) || [''])[0], + notes: body, + }; +} + +function stripCompanyParticle(token) { + return normalize(token) + .replace(/(?:은|는|이|가|을|를|에서|의)$/u, '') + .trim(); +} + +function inferNaturalStage(raw) { + if (/(탈락|불합격|리젝|rejected)/i.test(raw)) return 'rejected'; + if (/(철회|withdrawn)/i.test(raw)) return 'withdrawn'; + if (/(보류|on_hold)/i.test(raw)) return 'on_hold'; + if (/(오퍼|offer|합격)/i.test(raw) && !/서류\s*합격/i.test(raw)) return 'offer'; + if (/(캐주얼\s*(면접|면담)|면담)/i.test(raw)) return 'recruiter_contact'; + if (/(최종|final)\s*면접/i.test(raw)) return 'final_interview'; + if (/(2차|second)\s*면접/i.test(raw)) return 'interview_2'; + if (/(1차|first)\s*면접/i.test(raw)) return 'interview_1'; + if (/(코딩\s*테스트|코테|과제)/i.test(raw)) return 'coding_test'; + if (/(리크루터|recruiter|채용\s*담당자).*(연락|contact)/i.test(raw)) return 'recruiter_contact'; + if (/(서류|screening).*(통과|합격|검토|대기)/i.test(raw)) return 'screening'; + if (/(지원\s*완료|지원했|applied)/i.test(raw)) return 'applied'; + return ''; +} + +function actionTitleForStage(stage) { + const titles = { + recruiter_contact: '채용 담당자 연락', + screening: '서류 진행 확인', + coding_test: '코딩 테스트', + interview_1: '1차 면접', + interview_2: '2차 면접', + final_interview: '최종 면접', + offer: '오퍼 확인', + rejected: '결과 정리', + withdrawn: '철회 정리', + on_hold: '보류 상태 확인', + }; + return titles[stage] || '지원 상태 확인'; +} + +function actionTitleFromText(raw, stage) { + if (/캐주얼\s*면담/i.test(raw)) return '캐주얼 면담'; + if (/캐주얼\s*면접/i.test(raw)) return '캐주얼 면접'; + return actionTitleForStage(stage); +} + +function buildNaturalProcessNote(raw, stage, dueAt) { + const parts = []; + if (/서류\s*(통과|합격)/i.test(raw)) parts.push('서류 통과'); + if (dueAt && /(면접|면담)/i.test(raw)) parts.push(`${actionTitleFromText(raw, stage)} 일정은 ${dueAt}`); + if (/(대기중|대기\s*중|대기)/i.test(raw) && !parts.some((part) => /대기/.test(part))) { + parts.push(`${actionTitleFromText(raw, stage)} 대기중`); + } + const source = extractContactSource(raw); + if (source) parts.push(`${source}로 연락중`); + return parts.length ? parts.join(', ') : normalize(raw); +} + +function extractContactSource(raw) { + const text = String(raw || ''); + const scoped = text.match(/여기는\s*([^\s,,]+)\s*(?:로|으로)\s*연락\s*중/i); + if (scoped) return normalize(scoped[1]); + const match = text.match(/([^\s,,]+)\s*(?:로|으로)\s*연락\s*중/i); + return match ? normalize(match[1]) : ''; +} + +function parseNaturalProcessUpdate(raw, options = {}) { + if (!/(서류|면접|면담|코딩\s*테스트|코테|오퍼|탈락|불합격|보류|철회|지원\s*완료|지원했)/i.test(raw)) return null; + const lead = raw.match(/^([^\s,,]+)\s+(.+)$/); + if (!lead) return null; + const token = stripCompanyParticle(lead[1]); + const body = normalize(lead[2]); + if (!token || !body) return null; + if (/^(지원처|지원|채용|job|서류|면접|코테|코딩)$/i.test(token)) return null; + + const stage = inferNaturalStage(raw); + const dueAt = parseDateToken(raw, options); + if (!stage && !dueAt) return null; + + return { + action: 'process_update', + token, + stage, + nextActionTitle: dueAt ? actionTitleFromText(raw, stage) : '', + dueAt, + note: buildNaturalProcessNote(raw, stage, dueAt), + autoCreate: true, + source: extractContactSource(raw), + }; +} + +function cleanupDatedProcessBody(body) { + return normalize(body) + .replace(/^(그리고|또|,|,)\s*/i, '') + .replace(/\s*(그리고|또)\s*$/i, '') + .replace(/\s*(있고|있어|있음|예정이야|예정|이야|야|입니다|임)\s*$/i, '') + .trim(); +} + +function dueAtFromDateMatch(match, options = {}) { + const year = match[1] || tokyoDate(options.now).slice(0, 4); + const date = `${year}-${String(match[2]).padStart(2, '0')}-${String(match[3]).padStart(2, '0')}`; + const meridiem = String(match[4] || '').trim(); + let hour = match[5] == null || match[5] === '' ? null : Number(match[5]); + const minuteRaw = match[6] || match[7] || ''; + const minute = minuteRaw === '' ? 0 : Number(minuteRaw); + if (hour == null || !Number.isFinite(hour)) return date; + if (meridiem === '오후' && hour < 12) hour += 12; + if (meridiem === '오전' && hour === 12) hour = 0; + return `${date} ${String(hour).padStart(2, '0')}:${String(Number.isFinite(minute) ? minute : 0).padStart(2, '0')}`; +} + +function parseDatedProcessBody(body, dueAt) { + const cleaned = cleanupDatedProcessBody(body); + if (!cleaned) return null; + const stageMatch = cleaned.match(/(캐주얼\s*(?:면접|면담)|1차\s*면접|2차\s*면접|최종\s*면접|코딩\s*테스트|코테|면접|면담|오퍼|탈락|불합격|보류|철회)/i); + if (!stageMatch) return null; + + const token = stripCompanyParticle(cleaned.slice(0, stageMatch.index).trim()); + if (!token) return null; + const stageText = stageMatch[0]; + const stage = inferNaturalStage(stageText) || 'recruiter_contact'; + const title = actionTitleFromText(stageText, stage); + const source = extractContactSource(cleaned); + const noteParts = [`${title} 일정은 ${dueAt}`]; + if (source) noteParts.push(`${source}로 연락중`); + return { + action: 'process_update', + token, + stage, + nextActionTitle: title, + dueAt, + note: noteParts.join(', '), + autoCreate: true, + source, + }; +} + +function parseDatedProcessUpdates(raw, options = {}) { + if (!/(면접|면담|코딩\s*테스트|코테|오퍼|탈락|불합격|보류|철회)/i.test(raw)) return null; + const dateRe = /(?:(20\d{2})\s*년\s*)?(\d{1,2})\s*월\s*(\d{1,2})\s*일\s*(?:(오전|오후)?\s*(\d{1,2})(?::(\d{2})|\s*시(?:\s*(\d{1,2})\s*분?)?)?)?\s*(?:에|에는|까지|로)?/gi; + const matches = []; + let match; + while ((match = dateRe.exec(raw)) != null) { + matches.push({ + match, + start: match.index, + end: dateRe.lastIndex, + dueAt: dueAtFromDateMatch(match, options), + }); + } + if (!matches.length) return null; + + const items = []; + for (let i = 0; i < matches.length; i += 1) { + const cur = matches[i]; + const next = matches[i + 1]; + const body = raw.slice(cur.end, next ? next.start : raw.length); + const item = parseDatedProcessBody(body, cur.dueAt); + if (item) items.push(item); + } + if (items.length === 0) return null; + return items.length === 1 ? items[0] : { action: 'bulk_process_update', items }; +} + +function parseCommand(payload, options = {}) { + const raw = stripLead(payload); + if (!raw) return { action: 'empty' }; + + if (/^(도움말|help)$/i.test(raw)) return { action: 'help' }; + if (/(주간\s*요약|주간요약|weekly\s*summary)/i.test(raw)) return { action: 'weekly_summary' }; + if (/(이번\s*주|this\s*week|오늘).*(팔로업|follow.?up|액션|할\s*일|마감)|^(팔로업|액션)\s*(오늘|이번\s*주|목록)?/i.test(raw)) { + return { action: 'pending', range: /오늘/.test(raw) ? 'today' : 'week' }; + } + if (/^(목록|리스트|파이프라인|현황|구인\s*현황|채용\s*현황|지원\s*현황|list|status)(?:\s|$)/i.test(raw)) return { action: 'list' }; + + const search = raw.match(/^(검색|찾아|search)\s+(.+)$/i); + if (search) return { action: 'search', query: normalize(search[2]) }; + + const detail = raw.match(/^(상세|보기|summary|detail)\s+(.+)$/i); + if (detail) return { action: 'detail', token: normalize(detail[2]) }; + + const datedUpdates = parseDatedProcessUpdates(raw, options); + if (datedUpdates) return datedUpdates; + + const add = /^(추가|등록|add)\b/i.test(raw) || /회사(?:명)?\s*[=:]/i.test(raw) + ? parseAddCommand(raw) + : null; + if (add) return add; + + return parseStageCommand(raw) + || parseNextActionCommand(raw, options) + || parseNoteCommand(raw) + || parseContactCommand(raw) + || parseNaturalProcessUpdate(raw, options) + || { action: 'unknown', raw }; +} + +function formatOpportunityLine(row) { + const stage = row.current_stage || 'wishlist'; + const next = row.next_action_title || row.next_action_at + ? ` / 다음: ${row.next_action_title || '-'}${row.next_action_at ? ` (${row.next_action_at})` : ''}` + : ''; + return `- #${row.opportunity_id} ${row.company_name} / ${row.title} [${stageLabel(stage)}]${next}`; +} + +function buildListReply(rows) { + const lines = ['지원 파이프라인']; + if (!rows.length) { + lines.push('- 아직 기록이 없어.'); + return lines.join('\n'); + } + let currentStage = ''; + for (const row of rows) { + const stage = row.current_stage || 'wishlist'; + if (stage !== currentStage) { + currentStage = stage; + lines.push(`[${stageLabel(stage)}]`); + } + lines.push(formatOpportunityLine(row)); + } + return lines.join('\n'); +} + +function buildPendingReply(rows, range) { + const title = range === 'today' ? '오늘 확인할 지원 액션' : '이번 주까지 확인할 지원 액션'; + if (!rows.length) return `${title}\n- 없음`; + return [ + title, + ...rows.map((row) => `- ${row.due_at || '-'} #${row.id} ${row.company_name} / ${row.opportunity_title}: ${row.title}`), + ].join('\n'); +} + +function buildDetailReply(payload) { + const d = payload.detail; + const lines = [ + `${d.company_name} / ${d.title}`, + `- 단계: ${stageLabel(d.current_stage)}`, + `- 상태: ${statusLabel(d.status)}`, + `- 기술/위치: ${d.tech_stack || '-'} / ${d.opportunity_location || d.company_location || '-'}`, + `- 링크: ${d.jd_url || d.company_website || '-'}`, + ]; + const openAction = (payload.actions || []).find((row) => row.status === 'open'); + if (openAction) lines.push(`- 다음 액션: ${openAction.title}${openAction.due_at ? ` (${openAction.due_at})` : ''}`); + if (payload.contacts && payload.contacts.length) { + lines.push(`- 연락처: ${payload.contacts.slice(0, 3).map((row) => row.name || row.role || '담당자').join(', ')}`); + } + if (payload.timeline && payload.timeline.length) { + lines.push('- 최근 타임라인:'); + lines.push(...payload.timeline.slice(0, 5).map((row) => ` - ${String(row.event_at || '').slice(0, 10)} ${formatTimelineSummary(row.summary)}`)); + } + return lines.join('\n'); +} + +function buildWeeklySummaryReply(summary) { + const stageText = summary.byStage.length + ? summary.byStage.map((row) => `${stageLabel(row.current_stage)}:${row.count}`).join(', ') + : '-'; + const lines = [ + `지원 주간 요약 (${summary.since} ~ ${summary.today})`, + `- 전체 단계 분포: ${stageText}`, + `- 최근 7일 기록: ${summary.changedCount}건`, + `- 이번 주 액션: ${summary.pending.length}건`, + ]; + if (summary.pending.length) { + lines.push(...summary.pending.slice(0, 5).map((row) => ` - ${row.due_at || '-'} ${row.company_name}: ${row.title}`)); + } + if (summary.stageChanges.length) { + lines.push('- 단계 변경:'); + lines.push(...summary.stageChanges.map((row) => ` - ${row.company_name} / ${row.title}: ${formatTimelineSummary(row.summary)}`)); + } + return lines.join('\n'); +} + +function buildHelpReply() { + return [ + '지원 파이프라인 사용 예시', + '- 지원처 추가 회사명=OOO 포지션=Backend Engineer 링크=https://...', + '- OOO 현재 단계 interview_1로 변경', + '- 리크루터 메모 저장 OOO 다음 주에 답장 필요', + '- OOO 다음액션=포트폴리오 보내기 마감=2026-05-03', + '- 지원: 목록 / 지원: 검색 react / 지원: 상세 OOO / 지원: 주간요약', + ].join('\n'); +} + +function mutationDedupeMaterial(parsed) { + if (parsed.action === 'bulk_process_update') { + return `job:${parsed.action}:${JSON.stringify(parsed.items || [])}`; + } + return `job:${parsed.action}:${parsed.token || ''}:${parsed.query || ''}:${parsed.stage || ''}:${parsed.company && parsed.company.name || ''}:${parsed.opportunity && parsed.opportunity.title || ''}:${parsed.title || parsed.nextActionTitle || ''}:${parsed.dueAt || ''}:${parsed.content || parsed.note || ''}`; +} + +function applyProcessUpdate(parsed, event, payload, options = {}) { + let target = jobs.resolveOpportunity(parsed.token, options); + let stageResult = null; + if (!target && parsed.autoCreate) { + const created = jobs.createApplication({ + eventId: event.eventId, + rawText: payload, + company: { name: parsed.token }, + opportunity: { + title: '미정 포지션', + source: parsed.source, + }, + stage: parsed.stage || 'wishlist', + ownerNote: parsed.note, + }, options); + target = created.detail; + stageResult = { process: created.process, detail: created.detail, created: true }; + } + if (!target) return null; + + if (parsed.stage && !stageResult) { + stageResult = jobs.updateStage({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + stage: parsed.stage, + note: parsed.note, + }, options); + if (!stageResult) return null; + } + + let actionResult = null; + if (parsed.nextActionTitle && parsed.dueAt) { + actionResult = jobs.setNextAction({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + title: parsed.nextActionTitle, + dueAt: parsed.dueAt, + }, options); + if (!actionResult) return null; + } + + let noteResult = null; + if (parsed.note) { + noteResult = jobs.recordNote({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + content: parsed.note, + eventType: 'process_note', + tags: parseTags(parsed.note), + }, options); + if (!noteResult) return null; + } + + const detail = (actionResult && actionResult.detail) + || (stageResult && stageResult.detail) + || (noteResult && noteResult.detail) + || target; + return { stage: stageResult, nextAction: actionResult, note: noteResult, detail }; +} + +async function handleJobPipelineCommand(payload, options = {}) { + const parsed = parseCommand(payload, options); + if (parsed.action === 'empty') { + return { + route: 'job', + success: false, + action: 'error', + telegramReply: '지원 파이프라인 입력이 비어있어. 예: 지원처 추가 회사명=OOO 포지션=Backend Engineer', + }; + } + if (parsed.action === 'help') { + return { route: 'job', success: true, action: 'help', telegramReply: buildHelpReply() }; + } + if (parsed.action === 'list') { + const rows = jobs.listPipeline(options); + return { route: 'job', success: true, action: 'list', rows, telegramReply: buildListReply(rows) }; + } + if (parsed.action === 'pending') { + const rows = jobs.listPendingActions(parsed.range, options); + return { route: 'job', success: true, action: 'pending', rows, telegramReply: buildPendingReply(rows, parsed.range) }; + } + if (parsed.action === 'search') { + const rows = jobs.searchPipeline(parsed.query, options); + return { + route: 'job', + success: true, + action: 'search', + rows, + telegramReply: rows.length ? ['지원 검색 결과', ...rows.map(formatOpportunityLine)].join('\n') : '지원 검색 결과\n- 없음', + }; + } + if (parsed.action === 'detail') { + const detail = jobs.getDetail(parsed.token, options); + return detail + ? { route: 'job', success: true, action: 'detail', detail, telegramReply: buildDetailReply(detail) } + : { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + } + if (parsed.action === 'weekly_summary') { + const summary = jobs.buildWeeklySummary(options); + return { route: 'job', success: true, action: 'weekly_summary', summary, telegramReply: buildWeeklySummaryReply(summary) }; + } + if (!['add', 'stage', 'note', 'next_action', 'contact', 'process_update', 'bulk_process_update'].includes(parsed.action)) { + return { + route: 'job', + success: false, + action: 'unknown', + telegramReply: '지원 명령을 알아듣지 못했어. 예: 지원: 도움말', + }; + } + + const event = personalStorage.createEvent({ + route: 'job', + source: options.source || 'telegram', + rawText: payload, + normalizedText: normalize(payload), + payload: parsed, + dedupeMaterial: mutationDedupeMaterial(parsed), + }, options); + + if (event.duplicate) { + return { + route: 'job', + success: true, + action: 'duplicate', + eventId: event.eventId, + duplicate: true, + telegramReply: '같은 지원 파이프라인 요청이 이미 처리되어 중복 저장을 건너뛰었어.', + }; + } + + try { + if (parsed.action === 'add') { + const result = jobs.createApplication({ + eventId: event.eventId, + rawText: payload, + company: parsed.company, + opportunity: parsed.opportunity, + stage: parsed.stage, + }, options); + return { + route: 'job', + success: true, + action: 'add', + eventId: event.eventId, + entityId: result.opportunity && result.opportunity.id, + result, + telegramReply: [ + '지원처 추가 완료', + `- 회사: ${result.company.name}`, + `- 포지션: ${result.opportunity.title}`, + `- 단계: ${stageLabel(result.process.current_stage)}`, + ].join('\n'), + }; + } + if (parsed.action === 'bulk_process_update') { + const results = []; + for (const item of parsed.items || []) { + const result = applyProcessUpdate(item, event, payload, options); + if (!result) { + return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${item.token}` }; + } + results.push({ item, result }); + } + return { + route: 'job', + success: true, + action: 'bulk_process_update', + eventId: event.eventId, + result: results, + telegramReply: [ + `지원 일정 ${results.length}건 반영 완료`, + ...results.map(({ item }) => `- ${item.token}: ${item.nextActionTitle || actionTitleForStage(item.stage)}${item.dueAt ? ` / ${item.dueAt}` : ''}`), + ].join('\n'), + }; + } + if (parsed.action === 'stage') { + const result = jobs.updateStage({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + stage: parsed.stage, + }, options); + if (!result) { + return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + } + return { + route: 'job', + success: true, + action: 'stage', + eventId: event.eventId, + entityId: result.detail && result.detail.opportunity_id, + result, + telegramReply: `${result.detail.company_name} 단계 변경 완료: ${stageLabel(result.detail.current_stage)}`, + }; + } + if (parsed.action === 'note') { + const result = jobs.recordNote({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + content: parsed.content, + eventType: parsed.eventType, + tags: parseTags(parsed.content), + }, options); + if (!result) return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + return { + route: 'job', + success: true, + action: 'note', + eventId: event.eventId, + entityId: result.note && result.note.id, + result, + telegramReply: `${result.detail.company_name} 메모 저장 완료`, + }; + } + if (parsed.action === 'next_action') { + const result = jobs.setNextAction({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + title: parsed.title, + dueAt: parsed.dueAt, + priority: parsed.priority, + }, options); + if (!result) return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + return { + route: 'job', + success: true, + action: 'next_action', + eventId: event.eventId, + entityId: result.action && result.action.id, + result, + telegramReply: `${result.detail.company_name} 다음 액션 저장 완료: ${result.action.title}${result.action.due_at ? ` (${result.action.due_at})` : ''}`, + }; + } + if (parsed.action === 'contact') { + const result = jobs.recordContact({ + eventId: event.eventId, + rawText: payload, + token: parsed.token, + name: parsed.name, + role: parsed.role, + channel: parsed.channel, + contactUrlOrEmail: parsed.contactUrlOrEmail, + notes: parsed.notes, + }, options); + if (!result) return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + return { + route: 'job', + success: true, + action: 'contact', + eventId: event.eventId, + entityId: result.contact && result.contact.id, + result, + telegramReply: `${result.detail.company_name} 연락처 저장 완료: ${result.contact.name || result.contact.role || '담당자'}`, + }; + } + if (parsed.action === 'process_update') { + const result = applyProcessUpdate(parsed, event, payload, options); + if (!result) return { route: 'job', success: false, action: 'not_found', telegramReply: `지원 기록을 찾지 못했어: ${parsed.token}` }; + const detail = result.detail; + const lines = [`${detail.company_name} 반영 완료`]; + if (parsed.stage) lines.push(`- 단계: ${stageLabel(parsed.stage)}`); + if (parsed.nextActionTitle && parsed.dueAt) lines.push(`- 다음 액션: ${parsed.nextActionTitle} / ${parsed.dueAt}`); + if (parsed.note) lines.push(`- 메모: ${parsed.note}`); + return { + route: 'job', + success: true, + action: 'process_update', + eventId: event.eventId, + entityId: detail && detail.opportunity_id, + result, + telegramReply: lines.join('\n'), + }; + } + } catch (error) { + personalStorage.markEventFailed(event.eventId, error && error.message ? error.message : String(error), options); + return { + route: 'job', + success: false, + action: 'failed', + eventId: event.eventId, + telegramReply: `지원 파이프라인 처리 실패: ${error && error.message ? error.message : error}`, + }; + } + + return { + route: 'job', + success: false, + action: 'unsupported', + eventId: event.eventId, + telegramReply: `지원하지 않는 지원 명령: ${parsed.action}`, + }; +} + +module.exports = { + parseCommand, + handleJobPipelineCommand, +}; diff --git a/scripts/personal_job_pipeline_storage.js b/scripts/personal_job_pipeline_storage.js new file mode 100644 index 0000000..bc86bbf --- /dev/null +++ b/scripts/personal_job_pipeline_storage.js @@ -0,0 +1,919 @@ +const storage = require('./personal_storage'); + +const JOB_STAGES = Object.freeze([ + 'wishlist', + 'applied', + 'recruiter_contact', + 'screening', + 'coding_test', + 'interview_1', + 'interview_2', + 'final_interview', + 'offer', + 'rejected', + 'withdrawn', + 'on_hold', +]); + +const CLOSED_STAGES = new Set(['rejected', 'withdrawn']); + +function nowIso() { + return storage.nowIso(); +} + +function normalizeText(text) { + return storage.normalizeSpace(text); +} + +function sqlQuote(value) { + return storage.sqlQuote(value); +} + +function safeJson(value, fallback = '{}') { + try { + return JSON.stringify(value == null ? {} : value); + } catch (_) { + return fallback; + } +} + +function normalizeNullable(value) { + const text = normalizeText(value); + return text || null; +} + +function normalizeStage(value) { + const raw = String(value || '').trim().toLowerCase(); + const compact = raw.replace(/\s+/g, ''); + const alias = { + wish: 'wishlist', + 관심: 'wishlist', + 찜: 'wishlist', + 지원전: 'wishlist', + 지원: 'applied', + 지원완료: 'applied', + applied: 'applied', + recruiter: 'recruiter_contact', + 리크루터: 'recruiter_contact', + 연락: 'recruiter_contact', + 서류: 'screening', + 스크리닝: 'screening', + '서류통과': 'screening', + '서류합격': 'screening', + 과제: 'coding_test', + 코딩테스트: 'coding_test', + 코테: 'coding_test', + 면접1: 'interview_1', + '1차': 'interview_1', + '1차면접': 'interview_1', + 면접2: 'interview_2', + '2차': 'interview_2', + '2차면접': 'interview_2', + 최종: 'final_interview', + 최종면접: 'final_interview', + 캐주얼면접: 'recruiter_contact', + 캐주얼면담: 'recruiter_contact', + 면담: 'recruiter_contact', + 오퍼: 'offer', + 합격: 'offer', + 탈락: 'rejected', + 거절: 'withdrawn', + 보류: 'on_hold', + }; + const normalized = alias[raw] || alias[compact] || raw; + return JOB_STAGES.includes(normalized) ? normalized : ''; +} + +function normalizeStatus(stage, explicitStatus = '') { + const raw = String(explicitStatus || '').trim().toLowerCase(); + if (raw) return raw; + if (CLOSED_STAGES.has(stage)) return 'closed'; + if (stage === 'on_hold') return 'on_hold'; + return 'active'; +} + +function toNumberOrNull(value) { + if (value == null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + +function normalizePriority(value, fallback = 3) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(1, Math.min(5, Math.round(n))); +} + +function ensureDb(options = {}) { + return storage.ensureStorage(options); +} + +function insertTimelineEvent(input = {}, options = {}) { + const dbPath = ensureDb(options); + const eventAt = String(input.eventAt || nowIso()); + const createdAt = String(input.createdAt || nowIso()); + storage.runSql( + dbPath, + ` +INSERT INTO job_timeline_events ( + event_id, opportunity_id, company_id, event_type, event_at, summary, raw_text, meta_json, created_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(Number(input.opportunityId))}, + ${sqlQuote(Number(input.companyId))}, + ${sqlQuote(String(input.eventType || 'note').trim() || 'note')}, + ${sqlQuote(eventAt)}, + ${sqlQuote(normalizeText(input.summary) || '기록')}, + ${sqlQuote(normalizeNullable(input.rawText))}, + ${sqlQuote(safeJson(input.meta || {}))}, + ${sqlQuote(createdAt)} +); +`, + ); + return storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_timeline_events +WHERE event_id = ${sqlQuote(String(input.eventId || ''))} + AND opportunity_id = ${sqlQuote(Number(input.opportunityId))} + AND company_id = ${sqlQuote(Number(input.companyId))} + AND event_type = ${sqlQuote(String(input.eventType || 'note').trim() || 'note')} + AND event_at = ${sqlQuote(eventAt)} + AND summary = ${sqlQuote(normalizeText(input.summary) || '기록')} +ORDER BY id DESC +LIMIT 1; +`, + )[0] || null; +} + +function upsertCompany(input = {}, options = {}) { + const dbPath = ensureDb(options); + const now = String(input.createdAt || nowIso()); + const name = normalizeText(input.name); + if (!name) throw new Error('company name is required'); + + storage.runSql( + dbPath, + ` +INSERT INTO job_companies ( + event_id, name, website, country, location, work_mode, industry, notes, created_at, updated_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(name)}, + ${sqlQuote(normalizeNullable(input.website))}, + ${sqlQuote(normalizeNullable(input.country))}, + ${sqlQuote(normalizeNullable(input.location))}, + ${sqlQuote(normalizeNullable(input.workMode))}, + ${sqlQuote(normalizeNullable(input.industry))}, + ${sqlQuote(normalizeNullable(input.notes))}, + ${sqlQuote(now)}, + ${sqlQuote(now)} +) +ON CONFLICT(name) DO UPDATE SET + website = COALESCE(excluded.website, job_companies.website), + country = COALESCE(excluded.country, job_companies.country), + location = COALESCE(excluded.location, job_companies.location), + work_mode = COALESCE(excluded.work_mode, job_companies.work_mode), + industry = COALESCE(excluded.industry, job_companies.industry), + notes = COALESCE(excluded.notes, job_companies.notes), + updated_at = excluded.updated_at; +`, + ); + + return storage.runSqlJson( + dbPath, + `SELECT * FROM job_companies WHERE name = ${sqlQuote(name)} LIMIT 1;`, + )[0] || null; +} + +function findOpportunityByCompanyAndTitle(dbPath, companyId, title) { + const rows = storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_opportunities +WHERE company_id = ${sqlQuote(Number(companyId))} + AND LOWER(title) = LOWER(${sqlQuote(normalizeText(title))}) +ORDER BY id DESC +LIMIT 1; +`, + ); + return rows[0] || null; +} + +function upsertOpportunity(input = {}, options = {}) { + const dbPath = ensureDb(options); + const now = String(input.createdAt || nowIso()); + const companyId = Number(input.companyId); + const title = normalizeText(input.title || '미정 포지션'); + if (!Number.isFinite(companyId) || companyId <= 0) throw new Error('companyId is required'); + if (!title) throw new Error('opportunity title is required'); + + const existing = findOpportunityByCompanyAndTitle(dbPath, companyId, title); + if (existing) { + storage.runSql( + dbPath, + ` +UPDATE job_opportunities +SET team_or_project = COALESCE(${sqlQuote(normalizeNullable(input.teamOrProject))}, team_or_project), + employment_type = COALESCE(${sqlQuote(normalizeNullable(input.employmentType))}, employment_type), + tech_stack = COALESCE(${sqlQuote(normalizeNullable(input.techStack))}, tech_stack), + salary_min = COALESCE(${sqlQuote(toNumberOrNull(input.salaryMin))}, salary_min), + salary_max = COALESCE(${sqlQuote(toNumberOrNull(input.salaryMax))}, salary_max), + currency = COALESCE(${sqlQuote(normalizeNullable(input.currency))}, currency), + jd_url = COALESCE(${sqlQuote(normalizeNullable(input.jdUrl))}, jd_url), + source = COALESCE(${sqlQuote(normalizeNullable(input.source))}, source), + source_message = COALESCE(${sqlQuote(normalizeNullable(input.sourceMessage))}, source_message), + fit_score = COALESCE(${sqlQuote(toNumberOrNull(input.fitScore))}, fit_score), + interest_score = COALESCE(${sqlQuote(toNumberOrNull(input.interestScore))}, interest_score), + pass_probability = COALESCE(${sqlQuote(toNumberOrNull(input.passProbability))}, pass_probability), + priority = COALESCE(${sqlQuote(toNumberOrNull(input.priority))}, priority), + location = COALESCE(${sqlQuote(normalizeNullable(input.location))}, location), + updated_at = ${sqlQuote(now)} +WHERE id = ${sqlQuote(Number(existing.id))}; +`, + ); + return storage.runSqlJson( + dbPath, + `SELECT * FROM job_opportunities WHERE id = ${sqlQuote(Number(existing.id))} LIMIT 1;`, + )[0] || null; + } + + storage.runSql( + dbPath, + ` +INSERT INTO job_opportunities ( + event_id, company_id, title, team_or_project, employment_type, tech_stack, + salary_min, salary_max, currency, jd_url, source, source_message, + fit_score, interest_score, pass_probability, priority, location, created_at, updated_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(companyId)}, + ${sqlQuote(title)}, + ${sqlQuote(normalizeNullable(input.teamOrProject))}, + ${sqlQuote(normalizeNullable(input.employmentType))}, + ${sqlQuote(normalizeNullable(input.techStack))}, + ${sqlQuote(toNumberOrNull(input.salaryMin))}, + ${sqlQuote(toNumberOrNull(input.salaryMax))}, + ${sqlQuote(normalizeNullable(input.currency))}, + ${sqlQuote(normalizeNullable(input.jdUrl))}, + ${sqlQuote(normalizeNullable(input.source))}, + ${sqlQuote(normalizeNullable(input.sourceMessage))}, + ${sqlQuote(toNumberOrNull(input.fitScore))}, + ${sqlQuote(toNumberOrNull(input.interestScore))}, + ${sqlQuote(toNumberOrNull(input.passProbability))}, + ${sqlQuote(toNumberOrNull(input.priority))}, + ${sqlQuote(normalizeNullable(input.location))}, + ${sqlQuote(now)}, + ${sqlQuote(now)} +); +`, + ); + return findOpportunityByCompanyAndTitle(dbPath, companyId, title); +} + +function ensureApplicationProcess(input = {}, options = {}) { + const dbPath = ensureDb(options); + const now = String(input.createdAt || nowIso()); + const opportunityId = Number(input.opportunityId); + if (!Number.isFinite(opportunityId) || opportunityId <= 0) throw new Error('opportunityId is required'); + const stage = normalizeStage(input.stage) || 'wishlist'; + const status = normalizeStatus(stage, input.status); + const appliedAt = input.appliedAt || (stage === 'applied' ? now : null); + + storage.runSql( + dbPath, + ` +INSERT INTO job_application_processes ( + event_id, opportunity_id, current_stage, status, applied_at, last_contact_at, + next_action_at, owner_note, result_reason, created_at, updated_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(opportunityId)}, + ${sqlQuote(stage)}, + ${sqlQuote(status)}, + ${sqlQuote(normalizeNullable(appliedAt))}, + ${sqlQuote(normalizeNullable(input.lastContactAt))}, + ${sqlQuote(normalizeNullable(input.nextActionAt))}, + ${sqlQuote(normalizeNullable(input.ownerNote))}, + ${sqlQuote(normalizeNullable(input.resultReason))}, + ${sqlQuote(now)}, + ${sqlQuote(now)} +) +ON CONFLICT(opportunity_id) DO UPDATE SET + current_stage = excluded.current_stage, + status = excluded.status, + applied_at = COALESCE(job_application_processes.applied_at, excluded.applied_at), + last_contact_at = COALESCE(excluded.last_contact_at, job_application_processes.last_contact_at), + next_action_at = COALESCE(excluded.next_action_at, job_application_processes.next_action_at), + owner_note = COALESCE(excluded.owner_note, job_application_processes.owner_note), + result_reason = COALESCE(excluded.result_reason, job_application_processes.result_reason), + updated_at = excluded.updated_at; +`, + ); + + return storage.runSqlJson( + dbPath, + `SELECT * FROM job_application_processes WHERE opportunity_id = ${sqlQuote(opportunityId)} LIMIT 1;`, + )[0] || null; +} + +function getOpportunityDetailById(opportunityId, options = {}) { + const dbPath = ensureDb(options); + return storage.runSqlJson( + dbPath, + ` +SELECT + c.id AS company_id, + c.name AS company_name, + c.website AS company_website, + c.location AS company_location, + c.work_mode AS company_work_mode, + c.industry AS company_industry, + c.notes AS company_notes, + o.id AS opportunity_id, + o.title, + o.team_or_project, + o.employment_type, + o.tech_stack, + o.jd_url, + o.source, + o.fit_score, + o.interest_score, + o.pass_probability, + o.priority, + o.location AS opportunity_location, + p.id AS process_id, + p.current_stage, + p.status, + p.applied_at, + p.last_contact_at, + p.next_action_at, + p.owner_note, + p.result_reason, + p.updated_at AS process_updated_at +FROM job_opportunities o +JOIN job_companies c ON c.id = o.company_id +LEFT JOIN job_application_processes p ON p.opportunity_id = o.id +WHERE o.id = ${sqlQuote(Number(opportunityId))} +LIMIT 1; +`, + )[0] || null; +} + +function resolveOpportunity(token, options = {}) { + const dbPath = ensureDb(options); + const raw = normalizeText(token); + if (!raw) return null; + + if (/^\d+$/.test(raw)) { + const byId = getOpportunityDetailById(Number(raw), options); + if (byId) return byId; + } + + const like = `%${raw.toLowerCase()}%`; + return storage.runSqlJson( + dbPath, + ` +SELECT + c.id AS company_id, + c.name AS company_name, + c.website AS company_website, + c.location AS company_location, + c.work_mode AS company_work_mode, + c.industry AS company_industry, + c.notes AS company_notes, + o.id AS opportunity_id, + o.title, + o.team_or_project, + o.employment_type, + o.tech_stack, + o.jd_url, + o.source, + o.fit_score, + o.interest_score, + o.pass_probability, + o.priority, + o.location AS opportunity_location, + p.id AS process_id, + p.current_stage, + p.status, + p.applied_at, + p.last_contact_at, + p.next_action_at, + p.owner_note, + p.result_reason, + p.updated_at AS process_updated_at +FROM job_opportunities o +JOIN job_companies c ON c.id = o.company_id +LEFT JOIN job_application_processes p ON p.opportunity_id = o.id +WHERE LOWER(c.name) LIKE ${sqlQuote(like)} + OR LOWER(o.title) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(o.tech_stack, '')) LIKE ${sqlQuote(like)} +ORDER BY datetime(COALESCE(p.updated_at, o.updated_at)) DESC, o.id DESC +LIMIT 1; +`, + )[0] || null; +} + +function createApplication(input = {}, options = {}) { + const company = upsertCompany(input.company || {}, options); + const opportunity = upsertOpportunity({ + ...(input.opportunity || {}), + eventId: input.eventId, + companyId: company.id, + }, options); + const process = ensureApplicationProcess({ + eventId: input.eventId, + opportunityId: opportunity.id, + stage: input.stage || 'wishlist', + appliedAt: input.appliedAt, + ownerNote: input.ownerNote, + }, options); + insertTimelineEvent({ + eventId: input.eventId, + companyId: company.id, + opportunityId: opportunity.id, + eventType: 'application_created', + summary: `${company.name} / ${opportunity.title} 추가`, + rawText: input.rawText, + meta: { stage: process.current_stage }, + }, options); + return { + company, + opportunity, + process, + detail: getOpportunityDetailById(opportunity.id, options), + }; +} + +function updateStage(input = {}, options = {}) { + const target = resolveOpportunity(input.token, options); + if (!target) return null; + const stage = normalizeStage(input.stage); + if (!stage) throw new Error(`unsupported job stage: ${input.stage}`); + const process = ensureApplicationProcess({ + eventId: input.eventId, + opportunityId: target.opportunity_id, + stage, + status: normalizeStatus(stage), + ownerNote: input.note, + appliedAt: stage === 'applied' ? nowIso() : null, + }, options); + insertTimelineEvent({ + eventId: input.eventId, + companyId: target.company_id, + opportunityId: target.opportunity_id, + eventType: 'stage_changed', + summary: `단계 변경: ${target.current_stage || '-'} -> ${stage}`, + rawText: input.rawText, + meta: { from: target.current_stage || null, to: stage }, + }, options); + return { + process, + detail: getOpportunityDetailById(target.opportunity_id, options), + }; +} + +function recordContact(input = {}, options = {}) { + const target = resolveOpportunity(input.token, options); + if (!target) return null; + const dbPath = ensureDb(options); + const now = String(input.createdAt || nowIso()); + storage.runSql( + dbPath, + ` +INSERT INTO job_contacts ( + event_id, company_id, opportunity_id, name, role, channel, contact_url_or_email, + notes, created_at, updated_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(Number(target.company_id))}, + ${sqlQuote(Number(target.opportunity_id))}, + ${sqlQuote(normalizeNullable(input.name))}, + ${sqlQuote(normalizeNullable(input.role || 'recruiter'))}, + ${sqlQuote(normalizeNullable(input.channel))}, + ${sqlQuote(normalizeNullable(input.contactUrlOrEmail))}, + ${sqlQuote(normalizeNullable(input.notes))}, + ${sqlQuote(now)}, + ${sqlQuote(now)} +); +`, + ); + const contact = storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_contacts +WHERE event_id = ${sqlQuote(String(input.eventId || ''))} + AND company_id = ${sqlQuote(Number(target.company_id))} + AND opportunity_id = ${sqlQuote(Number(target.opportunity_id))} + AND created_at = ${sqlQuote(now)} +ORDER BY id DESC +LIMIT 1; +`, + )[0] || null; + insertTimelineEvent({ + eventId: input.eventId, + companyId: target.company_id, + opportunityId: target.opportunity_id, + eventType: 'contact_added', + summary: `연락처 추가: ${contact.name || contact.role || '담당자'}`, + rawText: input.rawText, + meta: { contactId: contact.id }, + }, options); + return { contact, detail: getOpportunityDetailById(target.opportunity_id, options) }; +} + +function recordNote(input = {}, options = {}) { + const target = resolveOpportunity(input.token, options); + if (!target) return null; + const dbPath = ensureDb(options); + const content = normalizeText(input.content); + if (!content) throw new Error('note content is required'); + const createdAt = String(input.createdAt || nowIso()); + storage.runSql( + dbPath, + ` +INSERT INTO job_notes ( + event_id, target_type, target_id, company_id, opportunity_id, content, tags_json, created_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote('opportunity')}, + ${sqlQuote(Number(target.opportunity_id))}, + ${sqlQuote(Number(target.company_id))}, + ${sqlQuote(Number(target.opportunity_id))}, + ${sqlQuote(content)}, + ${sqlQuote(safeJson(Array.isArray(input.tags) ? input.tags : []))}, + ${sqlQuote(createdAt)} +); +`, + ); + const note = storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_notes +WHERE event_id = ${sqlQuote(String(input.eventId || ''))} + AND company_id = ${sqlQuote(Number(target.company_id))} + AND opportunity_id = ${sqlQuote(Number(target.opportunity_id))} + AND content = ${sqlQuote(content)} + AND created_at = ${sqlQuote(createdAt)} +ORDER BY id DESC +LIMIT 1; +`, + )[0] || null; + insertTimelineEvent({ + eventId: input.eventId, + companyId: target.company_id, + opportunityId: target.opportunity_id, + eventType: input.eventType || 'note', + summary: content.slice(0, 120), + rawText: input.rawText, + meta: { noteId: note.id }, + }, options); + return { note, detail: getOpportunityDetailById(target.opportunity_id, options) }; +} + +function setNextAction(input = {}, options = {}) { + const target = resolveOpportunity(input.token, options); + if (!target) return null; + const dbPath = ensureDb(options); + const now = String(input.createdAt || nowIso()); + const title = normalizeText(input.title); + if (!title) throw new Error('next action title is required'); + const existing = storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_next_actions +WHERE opportunity_id = ${sqlQuote(Number(target.opportunity_id))} + AND status = 'open' + AND LOWER(title) = LOWER(${sqlQuote(title)}) +ORDER BY id DESC +LIMIT 1; +`, + )[0] || null; + if (existing) { + const prioritySql = input.priority == null || input.priority === '' + ? 'priority' + : sqlQuote(normalizePriority(input.priority)); + storage.runSql( + dbPath, + ` +UPDATE job_next_actions +SET due_at = ${sqlQuote(normalizeNullable(input.dueAt))}, + status = ${sqlQuote(String(input.status || 'open').trim().toLowerCase() || 'open')}, + priority = ${prioritySql}, + note = COALESCE(${sqlQuote(normalizeNullable(input.note))}, note), + updated_at = ${sqlQuote(now)}, + completed_at = NULL +WHERE id = ${sqlQuote(Number(existing.id))}; +`, + ); + } else { + storage.runSql( + dbPath, + ` +INSERT INTO job_next_actions ( + event_id, opportunity_id, title, due_at, status, priority, note, created_at, updated_at, completed_at +) +VALUES ( + ${sqlQuote(String(input.eventId || ''))}, + ${sqlQuote(Number(target.opportunity_id))}, + ${sqlQuote(title)}, + ${sqlQuote(normalizeNullable(input.dueAt))}, + ${sqlQuote(String(input.status || 'open').trim().toLowerCase() || 'open')}, + ${sqlQuote(normalizePriority(input.priority))}, + ${sqlQuote(normalizeNullable(input.note))}, + ${sqlQuote(now)}, + ${sqlQuote(now)}, + NULL +); +`, + ); + } + const action = existing + ? storage.runSqlJson(dbPath, `SELECT * FROM job_next_actions WHERE id = ${sqlQuote(Number(existing.id))} LIMIT 1;`)[0] || null + : storage.runSqlJson( + dbPath, + ` +SELECT * +FROM job_next_actions +WHERE event_id = ${sqlQuote(String(input.eventId || ''))} + AND opportunity_id = ${sqlQuote(Number(target.opportunity_id))} + AND title = ${sqlQuote(title)} + AND due_at IS ${normalizeNullable(input.dueAt) == null ? 'NULL' : sqlQuote(normalizeNullable(input.dueAt))} + AND created_at = ${sqlQuote(now)} +ORDER BY id DESC +LIMIT 1; +`, + )[0] || null; + storage.runSql( + dbPath, + ` +UPDATE job_application_processes +SET next_action_at = ${sqlQuote(normalizeNullable(input.dueAt))}, + updated_at = ${sqlQuote(now)} +WHERE opportunity_id = ${sqlQuote(Number(target.opportunity_id))}; +`, + ); + insertTimelineEvent({ + eventId: input.eventId, + companyId: target.company_id, + opportunityId: target.opportunity_id, + eventType: 'next_action_set', + summary: `다음 액션: ${title}${action.due_at ? ` (${action.due_at})` : ''}`, + rawText: input.rawText, + meta: { nextActionId: action.id }, + }, options); + return { action, detail: getOpportunityDetailById(target.opportunity_id, options) }; +} + +function listPipeline(options = {}) { + const dbPath = ensureDb(options); + const activeOnly = options.activeOnly !== false; + const where = activeOnly ? "WHERE COALESCE(p.status, 'active') != 'closed'" : ''; + return storage.runSqlJson( + dbPath, + ` +SELECT + c.name AS company_name, + o.id AS opportunity_id, + o.title, + o.tech_stack, + o.priority, + p.current_stage, + p.status, + p.next_action_at, + ( + SELECT ja.title + FROM job_next_actions ja + WHERE ja.opportunity_id = o.id AND ja.status = 'open' + ORDER BY ja.due_at IS NULL, ja.due_at ASC, ja.priority ASC, ja.id DESC + LIMIT 1 + ) AS next_action_title +FROM job_opportunities o +JOIN job_companies c ON c.id = o.company_id +LEFT JOIN job_application_processes p ON p.opportunity_id = o.id +${where} +ORDER BY + CASE p.current_stage + WHEN 'wishlist' THEN 0 + WHEN 'applied' THEN 1 + WHEN 'recruiter_contact' THEN 2 + WHEN 'screening' THEN 3 + WHEN 'coding_test' THEN 4 + WHEN 'interview_1' THEN 5 + WHEN 'interview_2' THEN 6 + WHEN 'final_interview' THEN 7 + WHEN 'offer' THEN 8 + WHEN 'on_hold' THEN 9 + WHEN 'rejected' THEN 10 + WHEN 'withdrawn' THEN 11 + ELSE 12 + END, + datetime(COALESCE(p.updated_at, o.updated_at)) DESC, + o.id DESC +LIMIT ${Math.max(1, Number(options.limit || 80))}; +`, + ); +} + +function searchPipeline(query, options = {}) { + const dbPath = ensureDb(options); + const q = normalizeText(query).toLowerCase(); + if (!q) return []; + const like = `%${q}%`; + return storage.runSqlJson( + dbPath, + ` +SELECT DISTINCT + c.name AS company_name, + o.id AS opportunity_id, + o.title, + o.tech_stack, + o.location AS opportunity_location, + p.current_stage, + p.status, + p.next_action_at +FROM job_opportunities o +JOIN job_companies c ON c.id = o.company_id +LEFT JOIN job_application_processes p ON p.opportunity_id = o.id +LEFT JOIN job_contacts ct ON ct.opportunity_id = o.id OR ct.company_id = c.id +WHERE LOWER(c.name) LIKE ${sqlQuote(like)} + OR LOWER(o.title) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(o.tech_stack, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(o.location, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(c.location, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(p.current_stage, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(p.status, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(ct.name, '')) LIKE ${sqlQuote(like)} + OR LOWER(COALESCE(ct.role, '')) LIKE ${sqlQuote(like)} +ORDER BY datetime(COALESCE(p.updated_at, o.updated_at)) DESC, o.id DESC +LIMIT ${Math.max(1, Number(options.limit || 20))}; +`, + ); +} + +function getDetail(token, options = {}) { + const detail = resolveOpportunity(token, options); + if (!detail) return null; + const dbPath = ensureDb(options); + const contacts = storage.runSqlJson( + dbPath, + ` +SELECT id, name, role, channel, contact_url_or_email, notes, updated_at +FROM job_contacts +WHERE opportunity_id = ${sqlQuote(Number(detail.opportunity_id))} + OR company_id = ${sqlQuote(Number(detail.company_id))} +ORDER BY datetime(updated_at) DESC, id DESC +LIMIT 8; +`, + ); + const notes = storage.runSqlJson( + dbPath, + ` +SELECT id, content, created_at +FROM job_notes +WHERE opportunity_id = ${sqlQuote(Number(detail.opportunity_id))} +ORDER BY datetime(created_at) DESC, id DESC +LIMIT 5; +`, + ); + const timeline = storage.runSqlJson( + dbPath, + ` +SELECT id, event_type, event_at, summary +FROM job_timeline_events +WHERE opportunity_id = ${sqlQuote(Number(detail.opportunity_id))} +ORDER BY datetime(event_at) DESC, id DESC +LIMIT 8; +`, + ); + const actions = storage.runSqlJson( + dbPath, + ` +SELECT id, title, due_at, status, priority, note +FROM job_next_actions +WHERE opportunity_id = ${sqlQuote(Number(detail.opportunity_id))} +ORDER BY CASE status WHEN 'open' THEN 0 ELSE 1 END, due_at IS NULL, due_at ASC, id DESC +LIMIT 8; +`, + ); + return { detail, contacts, notes, timeline, actions }; +} + +function tokyoDate(now = null) { + const date = now ? new Date(now) : new Date(); + const fmt = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Tokyo', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + return fmt.format(Number.isFinite(date.getTime()) ? date : new Date()); +} + +function addDays(dateText, days) { + const date = new Date(`${dateText}T00:00:00+09:00`); + date.setUTCDate(date.getUTCDate() + Number(days || 0)); + return tokyoDate(date); +} + +function listPendingActions(range = 'today', options = {}) { + const dbPath = ensureDb(options); + const today = tokyoDate(options.now); + const end = range === 'week' ? addDays(today, 7) : today; + return storage.runSqlJson( + dbPath, + ` +SELECT + ja.id, + ja.title, + ja.due_at, + ja.priority, + c.name AS company_name, + o.title AS opportunity_title, + p.current_stage +FROM job_next_actions ja +JOIN job_opportunities o ON o.id = ja.opportunity_id +JOIN job_companies c ON c.id = o.company_id +LEFT JOIN job_application_processes p ON p.opportunity_id = o.id +WHERE ja.status = 'open' + AND ja.due_at IS NOT NULL + AND substr(ja.due_at, 1, 10) <= ${sqlQuote(end)} +ORDER BY substr(ja.due_at, 1, 10) ASC, ja.priority ASC, ja.id DESC +LIMIT ${Math.max(1, Number(options.limit || 30))}; +`, + ); +} + +function buildWeeklySummary(options = {}) { + const dbPath = ensureDb(options); + const today = tokyoDate(options.now); + const since = addDays(today, -7); + const byStage = storage.runSqlJson( + dbPath, + ` +SELECT p.current_stage, COUNT(*) AS count +FROM job_application_processes p +GROUP BY p.current_stage +ORDER BY count DESC, p.current_stage ASC; +`, + ); + const changed = storage.runSqlJson( + dbPath, + ` +SELECT COUNT(*) AS count +FROM job_timeline_events +WHERE substr(event_at, 1, 10) >= ${sqlQuote(since)}; +`, + )[0] || { count: 0 }; + const stageChanges = storage.runSqlJson( + dbPath, + ` +SELECT c.name AS company_name, o.title, te.summary, te.event_at +FROM job_timeline_events te +JOIN job_companies c ON c.id = te.company_id +JOIN job_opportunities o ON o.id = te.opportunity_id +WHERE te.event_type = 'stage_changed' + AND substr(te.event_at, 1, 10) >= ${sqlQuote(since)} +ORDER BY datetime(te.event_at) DESC, te.id DESC +LIMIT 5; +`, + ); + const pending = listPendingActions('week', { ...options, limit: 8 }); + return { + today, + since, + byStage, + changedCount: Number(changed.count || 0), + stageChanges, + pending, + }; +} + +module.exports = { + JOB_STAGES, + normalizeStage, + createApplication, + updateStage, + recordContact, + recordNote, + setNextAction, + listPipeline, + searchPipeline, + getDetail, + listPendingActions, + buildWeeklySummary, + resolveOpportunity, + insertTimelineEvent, +}; diff --git a/scripts/personal_schema.js b/scripts/personal_schema.js index 9a8de7d..f404099 100644 --- a/scripts/personal_schema.js +++ b/scripts/personal_schema.js @@ -134,6 +134,130 @@ CREATE TABLE IF NOT EXISTS media_place_logs ( ); CREATE INDEX IF NOT EXISTS idx_media_place_kind_date ON media_place_logs(kind, visit_date DESC, created_at DESC); +CREATE TABLE IF NOT EXISTS job_companies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + name TEXT NOT NULL UNIQUE, + website TEXT, + country TEXT, + location TEXT, + work_mode TEXT, + industry TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_job_companies_name ON job_companies(name); + +CREATE TABLE IF NOT EXISTS job_opportunities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + company_id INTEGER NOT NULL, + title TEXT NOT NULL, + team_or_project TEXT, + employment_type TEXT, + tech_stack TEXT, + salary_min REAL, + salary_max REAL, + currency TEXT, + jd_url TEXT, + source TEXT, + source_message TEXT, + fit_score REAL, + interest_score REAL, + pass_probability REAL, + priority INTEGER, + location TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(company_id) REFERENCES job_companies(id) +); +CREATE INDEX IF NOT EXISTS idx_job_opportunities_company ON job_opportunities(company_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_job_opportunities_title ON job_opportunities(title); + +CREATE TABLE IF NOT EXISTS job_application_processes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + opportunity_id INTEGER NOT NULL UNIQUE, + current_stage TEXT NOT NULL DEFAULT 'wishlist', + status TEXT NOT NULL DEFAULT 'active', + applied_at TEXT, + last_contact_at TEXT, + next_action_at TEXT, + owner_note TEXT, + result_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(opportunity_id) REFERENCES job_opportunities(id) +); +CREATE INDEX IF NOT EXISTS idx_job_processes_stage ON job_application_processes(current_stage, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_job_processes_next_action ON job_application_processes(next_action_at); + +CREATE TABLE IF NOT EXISTS job_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + company_id INTEGER NOT NULL, + opportunity_id INTEGER, + name TEXT, + role TEXT, + channel TEXT, + contact_url_or_email TEXT, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(company_id) REFERENCES job_companies(id), + FOREIGN KEY(opportunity_id) REFERENCES job_opportunities(id) +); +CREATE INDEX IF NOT EXISTS idx_job_contacts_company ON job_contacts(company_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS job_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + target_type TEXT NOT NULL, + target_id INTEGER NOT NULL, + company_id INTEGER, + opportunity_id INTEGER, + content TEXT NOT NULL, + tags_json TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_job_notes_company ON job_notes(company_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_job_notes_opportunity ON job_notes(opportunity_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS job_timeline_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + opportunity_id INTEGER NOT NULL, + company_id INTEGER NOT NULL, + event_type TEXT NOT NULL, + event_at TEXT NOT NULL, + summary TEXT NOT NULL, + raw_text TEXT, + meta_json TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(opportunity_id) REFERENCES job_opportunities(id), + FOREIGN KEY(company_id) REFERENCES job_companies(id) +); +CREATE INDEX IF NOT EXISTS idx_job_timeline_opportunity ON job_timeline_events(opportunity_id, event_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_job_timeline_company ON job_timeline_events(company_id, event_at DESC, id DESC); + +CREATE TABLE IF NOT EXISTS job_next_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT, + opportunity_id INTEGER NOT NULL, + title TEXT NOT NULL, + due_at TEXT, + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 3, + note TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT, + FOREIGN KEY(opportunity_id) REFERENCES job_opportunities(id) +); +CREATE INDEX IF NOT EXISTS idx_job_next_actions_due ON job_next_actions(status, due_at, priority); +CREATE INDEX IF NOT EXISTS idx_job_next_actions_opportunity ON job_next_actions(opportunity_id, updated_at DESC); + CREATE TABLE IF NOT EXISTS sync_audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT, diff --git a/scripts/run_changed_tests.js b/scripts/run_changed_tests.js new file mode 100644 index 0000000..32e4fa3 --- /dev/null +++ b/scripts/run_changed_tests.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +function runGit(args) { + const res = spawnSync('git', args, { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 5 * 1024 * 1024, + }); + if (res.error || res.status !== 0) return []; + return String(res.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function collectChangedFiles(options = {}) { + if (Array.isArray(options.files) && options.files.length > 0) { + return [...new Set(options.files.map((file) => String(file || '').trim()).filter(Boolean))]; + } + const files = [ + ...runGit(['diff', '--name-only', '--cached', '--']), + ...runGit(['diff', '--name-only', '--']), + ...runGit(['ls-files', '--others', '--exclude-standard']), + ]; + return [...new Set(files)].sort(); +} + +function add(commands, command) { + if (!commands.includes(command)) commands.push(command); +} + +function planChangedTests(files = []) { + const commands = []; + const reasons = []; + const normalized = files.map((file) => String(file || '').trim()).filter(Boolean); + + for (const file of normalized) { + if (file === 'package.json' || file === 'package-lock.json' || file === '.gitignore') { + add(commands, 'npm run -s check:scripts'); + reasons.push({ file, command: 'npm run -s check:scripts' }); + } + if (/^scripts\/check_package_scripts\.js$|^scripts\/test_check_package_scripts\.js$/.test(file)) { + add(commands, 'node scripts/test_check_package_scripts.js'); + add(commands, 'npm run -s check:scripts'); + reasons.push({ file, command: 'node scripts/test_check_package_scripts.js' }); + } + if (/^scripts\/anki_|^scripts\/test_anki_/.test(file)) { + add(commands, 'npm run -s test:scope:anki'); + reasons.push({ file, command: 'npm run -s test:scope:anki' }); + } + if (/^scripts\/ops_|^scripts\/test_ops_|^ops\//.test(file)) { + add(commands, 'npm run -s test:ops'); + reasons.push({ file, command: 'npm run -s test:ops' }); + } + if (/^scripts\/bridge|^scripts\/lib\/bridge_|^scripts\/test_bridge_/.test(file)) { + add(commands, 'npm run -s test:v1-release'); + reasons.push({ file, command: 'npm run -s test:v1-release' }); + } + if (/^scripts\/news_|^scripts\/test_news_/.test(file)) { + add(commands, 'npm run -s test:news'); + reasons.push({ file, command: 'npm run -s test:news' }); + } + if (/^scripts\/personal_|^scripts\/test_personal_/.test(file)) { + add(commands, 'npm run -s test:personal'); + reasons.push({ file, command: 'npm run -s test:personal' }); + } + } + + if (commands.length === 0 && normalized.length > 0) { + add(commands, 'npm run -s test:smoke'); + reasons.push({ file: '*', command: 'npm run -s test:smoke' }); + } + + return { + ok: true, + files: normalized, + commands, + reasons, + }; +} + +function runCommand(command) { + const res = spawnSync(command, { + cwd: ROOT, + shell: true, + stdio: 'inherit', + env: process.env, + }); + return !res.error && res.status === 0; +} + +function parseArgs(argv) { + const out = { + dryRun: false, + json: false, + files: [], + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--dry-run') out.dryRun = true; + else if (arg === '--json') out.json = true; + else if (arg === '--file') { + i += 1; + out.files.push(argv[i]); + } else if (arg.startsWith('--file=')) { + out.files.push(arg.slice('--file='.length)); + } else { + out.files.push(arg); + } + } + return out; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const files = collectChangedFiles({ files: args.files }); + const plan = planChangedTests(files); + if (args.json || args.dryRun) { + console.log(JSON.stringify(plan, null, 2)); + } + if (args.dryRun) return; + for (const command of plan.commands) { + const ok = runCommand(command); + if (!ok) process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + collectChangedFiles, + planChangedTests, +}; diff --git a/scripts/runtime_bot.js b/scripts/runtime_bot.js new file mode 100644 index 0000000..4cb468f --- /dev/null +++ b/scripts/runtime_bot.js @@ -0,0 +1,128 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); +const { ROOT, composeEnvArgs } = require('./env_runtime'); + +const RUNTIMES = Object.freeze({ + daily: { service: 'openclaw-daily', container: 'moltbot-daily' }, + dev: { service: 'openclaw-dev', container: 'moltbot-dev' }, + anki: { service: 'openclaw-anki', container: 'moltbot-anki' }, + research: { service: 'openclaw-research', container: 'moltbot-research' }, +}); + +function runDocker(args) { + const res = spawnSync('docker', args, { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 5 * 1024 * 1024, + }); + return { + ok: !res.error && res.status === 0, + code: Number.isInteger(res.status) ? res.status : 1, + stdout: String(res.stdout || ''), + stderr: String(res.stderr || ''), + error: res.error ? String(res.error.message || res.error) : '', + }; +} + +function parseJsonLine(value) { + try { + return JSON.parse(value); + } catch (_) { + return null; + } +} + +function status(runtimeKey) { + const runtime = RUNTIMES[runtimeKey]; + const res = runDocker([ + 'ps', + '-a', + '--filter', + `name=^/${runtime.container}$`, + '--format', + '{{json .}}', + ]); + if (!res.ok) { + return { + ok: true, + runtime: runtimeKey, + service: runtime.service, + container: runtime.container, + dockerOk: false, + state: 'unknown', + error: res.error || res.stderr || `exit=${res.code}`, + }; + } + const line = res.stdout.split(/\r?\n/).map((v) => v.trim()).find(Boolean); + if (!line) { + return { + ok: true, + runtime: runtimeKey, + service: runtime.service, + container: runtime.container, + dockerOk: true, + state: 'missing', + }; + } + const parsed = parseJsonLine(line); + return { + ok: true, + runtime: runtimeKey, + service: runtime.service, + container: runtime.container, + dockerOk: true, + state: String((parsed && parsed.State) || (parsed && parsed.Status) || 'unknown'), + status: String((parsed && parsed.Status) || ''), + raw: parsed || line, + }; +} + +function composeAction(runtimeKey, action) { + const runtime = RUNTIMES[runtimeKey]; + const base = ['compose', ...composeEnvArgs({ allowLegacyFallback: true, required: false }), '--profile', 'live']; + const actionArgs = action === 'start' + ? ['up', '-d', runtime.service] + : action === 'stop' + ? ['stop', runtime.service] + : ['restart', runtime.service]; + const res = runDocker([...base, ...actionArgs]); + return { + ok: res.ok, + runtime: runtimeKey, + service: runtime.service, + action, + code: res.code, + stdout: res.stdout.trim(), + stderr: res.stderr.trim(), + error: res.error, + status: status(runtimeKey), + }; +} + +function main() { + const runtimeKey = String(process.argv[2] || '').trim(); + const action = String(process.argv[3] || 'status').trim(); + if (!RUNTIMES[runtimeKey] || !['status', 'start', 'stop', 'restart'].includes(action)) { + console.error(JSON.stringify({ + ok: false, + error: 'usage: node scripts/runtime_bot.js ', + }, null, 2)); + process.exit(1); + } + + const result = action === 'status' + ? status(runtimeKey) + : composeAction(runtimeKey, action); + console.log(JSON.stringify(result, null, 2)); + if (action !== 'status' && !result.ok) process.exit(1); +} + +if (require.main === module) { + main(); +} + +module.exports = { + RUNTIMES, + status, + composeAction, +}; diff --git a/scripts/standby_cutover_check.js b/scripts/standby_cutover_check.js new file mode 100644 index 0000000..c9bf061 --- /dev/null +++ b/scripts/standby_cutover_check.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const { spawnSync } = require('child_process'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const CONTAINERS = Object.freeze([ + 'moltbot-dev-bak', + 'moltbot-anki-bak', + 'moltbot-research-bak', + 'moltbot-daily-bak', +]); + +function parseJsonLine(value) { + try { + return JSON.parse(value); + } catch (_) { + return null; + } +} + +function inspectContainer(container) { + const res = spawnSync('docker', [ + 'ps', + '-a', + '--filter', + `name=^/${container}$`, + '--format', + '{{json .}}', + ], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (res.error || res.status !== 0) { + return { + container, + status: 'unknown', + dockerOk: false, + }; + } + const line = String(res.stdout || '').split(/\r?\n/).map((v) => v.trim()).find(Boolean); + if (!line) { + return { + container, + status: 'missing', + dockerOk: true, + }; + } + const parsed = parseJsonLine(line); + return { + container, + status: String((parsed && parsed.Status) || 'unknown'), + dockerOk: true, + raw: parsed || line, + }; +} + +const containers = CONTAINERS.map(inspectContainer); +const dockerOk = containers.some((row) => row.dockerOk); + +console.log(JSON.stringify({ + ok: true, + checkedAt: new Date().toISOString(), + dockerOk, + containers, +}, null, 2)); diff --git a/scripts/test_anki_connect.js b/scripts/test_anki_connect.js index 0941738..5790a78 100644 --- a/scripts/test_anki_connect.js +++ b/scripts/test_anki_connect.js @@ -73,6 +73,8 @@ async function testAllowModeAddCard() { calls.push({ action, params }); if (action === 'modelFieldNames') return ['Question', 'Answer']; if (action === 'addNote') return 77; + if (action === 'notesInfo') return [{ noteId: 77, cards: [7701] }]; + if (action === 'cardsInfo') return [{ cardId: 7701, deckName: 'TOEIC_AI' }]; return null; }); @@ -90,6 +92,8 @@ async function testFieldAutoMappingFrontBack() { const client = makeClient(async (action, params) => { if (action === 'modelFieldNames') return ['Front', 'Back', 'Extra']; if (action === 'addNote') return 99; + if (action === 'notesInfo') return [{ noteId: 99, cards: [9901] }]; + if (action === 'cardsInfo') return [{ cardId: 9901, deckName: 'TOEIC_AI' }]; return null; }); @@ -100,11 +104,187 @@ async function testFieldAutoMappingFrontBack() { assert.strictEqual(out.noteId, 99); } +async function testDeckAliasNormalizesBeforeAdd() { + const calls = []; + const client = makeClient(async (action, params) => { + calls.push({ action, params }); + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNote') return 88; + if (action === 'notesInfo') return [{ noteId: 88, cards: [8801] }]; + if (action === 'cardsInfo') return [{ cardId: 8801, deckName: 'TOEIC_AI' }]; + return null; + }); + + const out = await client.addCard('toeic_ai', 'alias', 'back', [], { + sync: false, + dedupeMode: 'allow', + }); + const addCall = calls.find((c) => c.action === 'addNote'); + assert.ok(addCall, 'addNote should be called'); + assert.strictEqual(addCall.params.note.deckName, 'TOEIC_AI'); + assert.strictEqual(out.requestedDeck, 'TOEIC_AI'); + assert.strictEqual(out.actualDeck, 'TOEIC_AI'); + assert.strictEqual(out.deckVerificationSkipped, false); +} + +async function testDeckInfoDelayRetriesAndSucceeds() { + let notesInfoCalls = 0; + let cardsInfoCalls = 0; + const client = makeClient(async (action, params) => { + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNote') return 101; + if (action === 'notesInfo') { + notesInfoCalls += 1; + if (notesInfoCalls === 1) return []; + return [{ noteId: 101, cards: [10101] }]; + } + if (action === 'cardsInfo') { + cardsInfoCalls += 1; + if (cardsInfoCalls === 1) return []; + return [{ cardId: 10101, deckName: 'TOEIC_AI' }]; + } + return null; + }); + + const out = await client.addCard('TOEIC_AI', 'delay', 'back', [], { + sync: false, + dedupeMode: 'allow', + deckVerificationDelayMs: 0, + }); + assert.strictEqual(out.action, 'add'); + assert.strictEqual(out.deckVerificationSkipped, false); + assert.strictEqual(out.actualDeck, 'TOEIC_AI'); + assert.ok(notesInfoCalls > 1, 'notesInfo should retry'); + assert.ok(cardsInfoCalls > 1, 'cardsInfo should retry'); +} + +async function testDeckVerificationSkippedStillSucceeds() { + const client = makeClient(async (action, params) => { + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNote') return 102; + if (action === 'notesInfo') return []; + return null; + }); + + const out = await client.addCard('TOEIC_AI', 'skip-verify', 'back', [], { + sync: false, + dedupeMode: 'allow', + deckVerificationRetries: 1, + deckVerificationDelayMs: 0, + }); + assert.strictEqual(out.action, 'add'); + assert.strictEqual(out.noteId, 102); + assert.strictEqual(out.deckVerificationSkipped, true); + assert.strictEqual(out.deckVerificationSkipReason, 'notes_info_unavailable'); +} + +async function testDeckMismatchSelfHeals() { + const calls = []; + const client = makeClient(async (action, params) => { + calls.push({ action, params }); + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNote') return 103; + if (action === 'notesInfo') return [{ noteId: 103, cards: [10301] }]; + if (action === 'cardsInfo') return [{ cardId: 10301, deckName: 'Default' }]; + if (action === 'changeDeck') return null; + return null; + }); + + const out = await client.addCard('TOEIC_AI', 'recover', 'back', [], { + sync: false, + dedupeMode: 'allow', + deckVerificationDelayMs: 0, + }); + const changeDeckCall = calls.find((c) => c.action === 'changeDeck'); + assert.ok(changeDeckCall, 'changeDeck should be called'); + assert.deepStrictEqual(changeDeckCall.params.cards, [10301]); + assert.strictEqual(changeDeckCall.params.deck, 'TOEIC_AI'); + assert.strictEqual(out.deckMismatchRecovered, true); + assert.strictEqual(out.actualDeck, 'TOEIC_AI'); +} + +async function testAddCardsUsesBatchAddNotesAndSingleVerification() { + const calls = []; + let syncCalls = 0; + const client = makeClient(async (action, params) => { + calls.push({ action, params }); + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNotes') return [201, 202]; + if (action === 'notesInfo') { + assert.deepStrictEqual(params.notes, [201, 202]); + return [ + { noteId: 201, cards: [20101] }, + { noteId: 202, cards: [20201] }, + ]; + } + if (action === 'cardsInfo') { + assert.deepStrictEqual(params.cards, [20101, 20201]); + return [ + { cardId: 20101, deckName: 'TOEIC_AI' }, + { cardId: 20201, deckName: 'TOEIC_AI' }, + ]; + } + return null; + }); + client.syncWithDelay = async () => { + syncCalls += 1; + return null; + }; + + const out = await client.addCards('toeic_ai', [ + { front: 'batch-1', back: 'back-1' }, + { front: 'batch-2', back: 'back-2', tags: ['extra'] }, + ], ['toeic'], { + deckVerificationDelayMs: 0, + }); + const addNotesCall = calls.find((c) => c.action === 'addNotes'); + assert.ok(addNotesCall, 'addNotes should be called'); + assert.strictEqual(addNotesCall.params.notes.length, 2); + assert.strictEqual(addNotesCall.params.notes[0].deckName, 'TOEIC_AI'); + assert.strictEqual(out.action, 'batch_add'); + assert.strictEqual(out.added, 2); + assert.strictEqual(out.deckVerificationMode, 'batch'); + assert.strictEqual(out.results.every((row) => row.deckVerificationSkipped === false), true); + assert.strictEqual(syncCalls, 1); +} + +async function testAddCardsBatchDeckMismatchSelfHeals() { + const calls = []; + const client = makeClient(async (action, params) => { + calls.push({ action, params }); + if (action === 'modelFieldNames') return ['Question', 'Answer']; + if (action === 'addNotes') return [301]; + if (action === 'notesInfo') return [{ noteId: 301, cards: [30101] }]; + if (action === 'cardsInfo') return [{ cardId: 30101, deckName: 'Default' }]; + if (action === 'changeDeck') return null; + return null; + }); + + const out = await client.addCards('TOEIC_AI', [ + { front: 'batch-recover', back: 'back' }, + ], [], { + sync: false, + deckVerificationDelayMs: 0, + }); + const changeDeckCall = calls.find((c) => c.action === 'changeDeck'); + assert.ok(changeDeckCall, 'changeDeck should be called for batch mismatch'); + assert.deepStrictEqual(changeDeckCall.params.cards, [30101]); + assert.strictEqual(changeDeckCall.params.deck, 'TOEIC_AI'); + assert.strictEqual(out.results[0].deckMismatchRecovered, true); + assert.strictEqual(out.results[0].actualDeck, 'TOEIC_AI'); +} + async function run() { await testSkipDuplicateMode(); await testUpdateDuplicateMode(); await testAllowModeAddCard(); await testFieldAutoMappingFrontBack(); + await testDeckAliasNormalizesBeforeAdd(); + await testDeckInfoDelayRetriesAndSucceeds(); + await testDeckVerificationSkippedStillSucceeds(); + await testDeckMismatchSelfHeals(); + await testAddCardsUsesBatchAddNotesAndSingleVerification(); + await testAddCardsBatchDeckMismatchSelfHeals(); console.log('test_anki_connect: ok'); } diff --git a/scripts/test_bridge_allowlist.js b/scripts/test_bridge_allowlist.js index 4196d53..da15f4b 100644 --- a/scripts/test_bridge_allowlist.js +++ b/scripts/test_bridge_allowlist.js @@ -47,6 +47,9 @@ function main() { const allowedFinance = runBridge(['auto', '가계: 점심 1200엔']); assert.strictEqual(allowedFinance.route, 'finance'); + const allowedJob = runBridge(['auto', '지원: 도움말']); + assert.strictEqual(allowedJob.route, 'job'); + const blockedAutoRoute = runBridge( ['auto', '작업: 요청: x; 대상: y; 완료기준: z'], { BRIDGE_ALLOWLIST_AUTO_ROUTES: 'link,status' }, diff --git a/scripts/test_bridge_hub_delegation.js b/scripts/test_bridge_hub_delegation.js index be58924..e7d7e39 100644 --- a/scripts/test_bridge_hub_delegation.js +++ b/scripts/test_bridge_hub_delegation.js @@ -27,7 +27,7 @@ function runAuto(message, env = {}) { MOLTBOT_BOT_ID: 'bot-daily', BRIDGE_ALLOWLIST_ENABLED: 'true', BRIDGE_ALLOWLIST_DIRECT_COMMANDS: 'auto', - BRIDGE_ALLOWLIST_AUTO_ROUTES: 'word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place', + BRIDGE_ALLOWLIST_AUTO_ROUTES: 'word,memo,news,report,work,inspect,deploy,project,prompt,link,status,ops,finance,todo,routine,workout,media,place,job', ...env, }, }); @@ -77,6 +77,10 @@ function main() { assert.strictEqual(financeOut.route, 'finance'); assert.ok(!financeOut.delegated, 'finance route must stay local on daily hub'); + const jobOut = runAuto('지원: 도움말'); + assert.strictEqual(jobOut.route, 'job'); + assert.ok(!jobOut.delegated, 'job route must stay local on daily hub'); + fs.rmSync(queued.queuePath, { force: true }); console.log('test_bridge_hub_delegation: ok'); diff --git a/scripts/test_bridge_natural_language_routing.js b/scripts/test_bridge_natural_language_routing.js index 4228c43..df22586 100644 --- a/scripts/test_bridge_natural_language_routing.js +++ b/scripts/test_bridge_natural_language_routing.js @@ -93,6 +93,31 @@ function main() { const workout = runRouteWithEnv('러닝 30분 5km 운동 기록해줘', inferEnv); assert.strictEqual(workout.route, 'workout'); + const jobAdd = runRouteWithEnv('지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://example.com/jobs/1', inferEnv); + assert.strictEqual(jobAdd.route, 'job'); + + const jobStage = runRouteWithEnv('Acme 현재 단계 interview_1로 변경', inferEnv); + assert.strictEqual(jobStage.route, 'job'); + assert.strictEqual(jobStage.inferredBy, 'natural-language:job'); + + const jobFollowup = runRouteWithEnv('이번 주 팔로업 필요한 회사 보여줘', inferEnv); + assert.strictEqual(jobFollowup.route, 'job'); + + const jobStatus = runRouteWithEnv('구인 현황', inferEnv); + assert.strictEqual(jobStatus.route, 'job'); + + const jobNaturalInterview = runRouteWithEnv('니지박스 서류 통과해서 1차 면접 대기중이고 1차 면접일은 5월14일 19시야', inferEnv); + assert.strictEqual(jobNaturalInterview.route, 'job'); + assert.strictEqual(jobNaturalInterview.inferredBy, 'natural-language:job'); + + const jobBulkInterviews = runRouteWithEnv('그리고 5월14일 16시에 eba테크 1차면접 있고 5월 1일 15:30에 datum studio 캐주얼면접 있어', inferEnv); + assert.strictEqual(jobBulkInterviews.route, 'job'); + assert.strictEqual(jobBulkInterviews.inferredBy, 'natural-language:job'); + + const jobCasualBizreach = runRouteWithEnv('5월 1일 17:30에 멤버스 캐주얼면담 있고 여기는 비즈리치로 연락중', inferEnv); + assert.strictEqual(jobCasualBizreach.route, 'job'); + assert.strictEqual(jobCasualBizreach.inferredBy, 'natural-language:job'); + const work = runRouteWithEnv('브릿지 라우터 리팩터링해줘', inferEnv); assert.strictEqual(work.route, 'work'); assert.strictEqual(work.inferredBy, 'natural-language:work'); diff --git a/scripts/test_bridge_route_dispatch.js b/scripts/test_bridge_route_dispatch.js index 0c05ed0..8001769 100644 --- a/scripts/test_bridge_route_dispatch.js +++ b/scripts/test_bridge_route_dispatch.js @@ -16,6 +16,12 @@ function main() { }); assert.deepStrictEqual(prefixed, { route: 'memo', payload: '오늘 회고' }); + const jobPrefixed = routeByPrefix('지원처 추가 회사명=Acme 포지션=Backend Engineer', { + commandPrefixes: {}, + normalizeIncomingCommandText: (text) => String(text || '').trim(), + }); + assert.deepStrictEqual(jobPrefixed, { route: 'job', payload: '추가 회사명=Acme 포지션=Backend Engineer' }); + const approve = routeByPrefix('승인 abc123', { normalizeIncomingCommandText: (text) => String(text || '').trim(), parseApproveShorthand: () => ({ normalizedPayload: '액션: 승인; 토큰: abc123' }), diff --git a/scripts/test_check_package_scripts.js b/scripts/test_check_package_scripts.js new file mode 100644 index 0000000..0d27e06 --- /dev/null +++ b/scripts/test_check_package_scripts.js @@ -0,0 +1,54 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { checkPackageScripts } = require('./check_package_scripts'); + +function writeFile(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'x', 'utf8'); +} + +function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-package-scripts-')); + try { + writeFile(path.join(root, 'scripts', 'good.js')); + writeFile(path.join(root, 'scripts', 'ok.sh')); + fs.mkdirSync(path.join(root, 'packages'), { recursive: true }); + + const good = checkPackageScripts({ + root, + packageJson: { + scripts: { + good: 'node scripts/good.js && npm run -s child', + child: 'bash scripts/ok.sh', + }, + workspaces: ['packages/*'], + }, + }); + assert.strictEqual(good.ok, true); + + const bad = checkPackageScripts({ + root, + packageJson: { + scripts: { + bad: 'node scripts/missing.js && node apps/bot/src/main.js && npm run -s nope', + }, + workspaces: ['apps/*', 'missing/*'], + }, + }); + const issueTypes = new Set(bad.issues.map((issue) => issue.type)); + assert.strictEqual(bad.ok, false); + assert.ok(issueTypes.has('missing_script_file')); + assert.ok(issueTypes.has('apps_ref_not_allowed')); + assert.ok(issueTypes.has('missing_npm_script')); + assert.ok(issueTypes.has('apps_workspace_not_allowed')); + assert.ok(issueTypes.has('missing_workspace_base')); + + console.log('test_check_package_scripts: ok'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/test_oai_api_router.js b/scripts/test_oai_api_router.js index 436573f..c74d066 100644 --- a/scripts/test_oai_api_router.js +++ b/scripts/test_oai_api_router.js @@ -64,6 +64,14 @@ function testRouteDefaults() { }); assert.strictEqual(local.apiLane, 'local-only'); assert.strictEqual(local.authMode, 'none'); + + const job = decideApiLane({ route: 'job', commandText: '지원: 목록' }, { + policy, + env, + budgetPolicy: { monthlyApiBudgetYen: 0, paidApiRequiresApproval: true }, + }); + assert.strictEqual(job.apiLane, 'local-only'); + assert.strictEqual(job.authMode, 'none'); } function testFeatureOverrideAndManualOverride() { @@ -186,6 +194,9 @@ function testRouteInference() { const inferredTodo = inferRouteFromCommand('투두: 추가 장보기'); assert.strictEqual(inferredTodo.route, 'todo'); + const inferredJob = inferRouteFromCommand('지원처: 추가 회사명=Acme 포지션=Backend Engineer'); + assert.strictEqual(inferredJob.route, 'job'); + const none = inferRouteFromCommand('그냥 대화'); assert.strictEqual(none.route, 'none'); } diff --git a/scripts/test_ops_alerts_compact.js b/scripts/test_ops_alerts_compact.js new file mode 100644 index 0000000..290b6a9 --- /dev/null +++ b/scripts/test_ops_alerts_compact.js @@ -0,0 +1,61 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { runAlertCompact } = require('./ops_alerts_compact'); + +function writeAlert(root, name, payload) { + const filePath = path.join(root, 'ops', 'alerts', 'sent', name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + return filePath; +} + +function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-alerts-compact-')); + try { + const a = writeAlert(root, 'a.json', { + alert_id: 'a', + issue_id: 'bot-dev_bot_down', + severity: 'P2', + created_at: '2026-04-01T01:00:00.000Z', + }); + const b = writeAlert(root, 'b.json', { + alert_id: 'b', + issue_id: 'bot-dev_bot_down', + severity: 'P2', + created_at: '2026-04-01T02:00:00.000Z', + }); + const single = writeAlert(root, 'single.json', { + alert_id: 'c', + issue_id: 'bot-anki_bot_down', + severity: 'P3', + created_at: '2026-04-01T03:00:00.000Z', + }); + fs.symlinkSync(a, path.join(root, 'ops', 'alerts', 'sent', 'link.json')); + + const dryRun = runAlertCompact({ root, apply: false }); + assert.strictEqual(dryRun.ok, true); + assert.strictEqual(dryRun.groupCount, 1); + assert.strictEqual(dryRun.fileCount, 2); + assert.ok(fs.existsSync(a)); + assert.ok(fs.existsSync(b)); + assert.ok(fs.existsSync(single)); + assert.ok(dryRun.skipped.some((row) => row.reason === 'symlink')); + + const applied = runAlertCompact({ root, apply: true }); + assert.strictEqual(applied.ok, true); + assert.strictEqual(applied.written.length, 1); + assert.strictEqual(applied.deleted.length, 2); + assert.ok(!fs.existsSync(a)); + assert.ok(!fs.existsSync(b)); + assert.ok(fs.existsSync(single)); + assert.ok(fs.existsSync(path.join(root, applied.written[0]))); + + console.log('test_ops_alerts_compact: ok'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/test_ops_approval_grant_flow.js b/scripts/test_ops_approval_grant_flow.js index 8158cc4..62babff 100644 --- a/scripts/test_ops_approval_grant_flow.js +++ b/scripts/test_ops_approval_grant_flow.js @@ -17,6 +17,7 @@ function runWorker() { encoding: 'utf8', env: { ...process.env, + MOLTBOT_BOT_ID: 'bot-daily', SKILL_FEEDBACK_AUTORUN: '0', }, }); diff --git a/scripts/test_ops_capability_worker.js b/scripts/test_ops_capability_worker.js index cb85930..e51f984 100644 --- a/scripts/test_ops_capability_worker.js +++ b/scripts/test_ops_capability_worker.js @@ -36,6 +36,7 @@ function runWorker() { encoding: 'utf8', env: { ...process.env, + MOLTBOT_BOT_ID: 'bot-daily', SKILL_FEEDBACK_AUTORUN: '0', TELEGRAM_FINALIZER_ECHO_ONLY: 'true', }, diff --git a/scripts/test_ops_dashboard.js b/scripts/test_ops_dashboard.js new file mode 100644 index 0000000..09eff54 --- /dev/null +++ b/scripts/test_ops_dashboard.js @@ -0,0 +1,69 @@ +const assert = require('assert'); +const { assessOpsDashboardHealth, compactOpsDashboard } = require('./ops_dashboard'); + +function makePayload(overrides = {}) { + return { + queues: { + outbox: 0, + pending: 0, + pendingApprovals: 0, + }, + recentFailures: [], + stateFiles: [ + { path: 'ops/state/state.json', exists: true }, + { path: 'ops/state/issues.json', exists: true }, + ], + artifactSizes: { + logsBytes: 1, + reportsBytes: 1, + opsBytes: 1, + }, + docker: [ + { id: 'daily', dockerOk: true, status: 'Up 1 minute' }, + { id: 'dev', dockerOk: true, status: 'Up 1 minute' }, + ], + ...overrides, + }; +} + +function main() { + const ok = assessOpsDashboardHealth(makePayload()); + assert.strictEqual(ok.level, 'ok'); + assert.strictEqual(ok.label, '정상'); + + const warning = assessOpsDashboardHealth(makePayload({ + queues: { + outbox: 0, + pending: 0, + pendingApprovals: 2, + }, + })); + assert.strictEqual(warning.level, 'warning'); + assert.strictEqual(warning.label, '주의'); + + const danger = assessOpsDashboardHealth(makePayload({ + recentFailures: [{}, {}, {}, {}, {}], + })); + assert.strictEqual(danger.level, 'danger'); + assert.strictEqual(danger.label, '위험'); + + const dockerDanger = assessOpsDashboardHealth(makePayload({ + docker: [ + { id: 'daily', dockerOk: false, status: 'unknown' }, + ], + })); + assert.strictEqual(dockerDanger.level, 'danger'); + + const compact = compactOpsDashboard(makePayload({ + docker: [ + { id: 'daily', container: 'moltbot-daily', dockerOk: true, status: 'Up', raw: { large: true } }, + ], + })); + assert.deepStrictEqual(compact.docker, [ + { id: 'daily', container: 'moltbot-daily', dockerOk: true, status: 'Up' }, + ]); + + console.log('test_ops_dashboard: ok'); +} + +main(); diff --git a/scripts/test_ops_runtime_gc.js b/scripts/test_ops_runtime_gc.js new file mode 100644 index 0000000..2be0cbb --- /dev/null +++ b/scripts/test_ops_runtime_gc.js @@ -0,0 +1,74 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { collectRuntimeGcPlan, compactRuntimeGcResult, runRuntimeGc } = require('./ops_runtime_gc'); + +function touch(filePath, ageDays) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'x', 'utf8'); + const when = new Date(Date.now() - (ageDays * 24 * 60 * 60 * 1000)); + fs.utimesSync(filePath, when, when); +} + +function main() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-runtime-gc-')); + const oldLog = path.join(root, 'logs', 'old.log'); + const newLog = path.join(root, 'logs', 'new.log'); + const oldReport = path.join(root, 'reports', 'old.json'); + const linkPath = path.join(root, 'logs', 'old-link.log'); + const keepFile = path.join(root, 'logs', '.gitkeep'); + try { + touch(oldLog, 31); + touch(newLog, 1); + touch(oldReport, 31); + touch(keepFile, 31); + fs.symlinkSync(oldLog, linkPath); + + const dryRun = runRuntimeGc({ + root, + days: 30, + targets: ['logs', 'reports'], + apply: false, + }); + assert.strictEqual(dryRun.ok, true); + assert.strictEqual(dryRun.apply, false); + assert.ok(dryRun.candidates.some((row) => row.path === 'logs/old.log')); + assert.ok(dryRun.candidates.some((row) => row.path === 'reports/old.json')); + assert.ok(!dryRun.candidates.some((row) => row.path === 'logs/new.log')); + assert.ok(!dryRun.candidates.some((row) => row.path === 'logs/.gitkeep')); + assert.ok(dryRun.skipped.some((row) => row.path === 'logs/.gitkeep' && row.reason === 'keep_file')); + assert.ok(dryRun.skipped.some((row) => row.path === 'logs/old-link.log' && row.reason === 'symlink')); + assert.ok(fs.existsSync(oldLog), 'dry-run should not delete old file'); + const compact = compactRuntimeGcResult(dryRun, { limit: 1 }); + assert.strictEqual(compact.candidateCount, 2); + assert.strictEqual(compact.candidateSample.length, 1); + assert.strictEqual(compact.hasMoreCandidates, true); + assert.strictEqual(Object.prototype.hasOwnProperty.call(compact, 'candidates'), false); + + const apply = runRuntimeGc({ + root, + days: 30, + targets: ['logs', 'reports'], + apply: true, + }); + assert.strictEqual(apply.ok, true); + assert.ok(!fs.existsSync(oldLog), 'apply should delete old log'); + assert.ok(!fs.existsSync(oldReport), 'apply should delete old report'); + assert.ok(fs.existsSync(newLog), 'apply should keep new log'); + assert.ok(fs.existsSync(keepFile), 'apply should keep .gitkeep'); + assert.ok(fs.lstatSync(linkPath).isSymbolicLink(), 'apply should keep symlink itself'); + + const outside = collectRuntimeGcPlan({ + root, + days: 0, + targets: ['../outside'], + }); + assert.ok(outside.skipped.some((row) => row.reason === 'outside_root')); + console.log('test_ops_runtime_gc: ok'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/test_package_scripts_manifest.js b/scripts/test_package_scripts_manifest.js new file mode 100644 index 0000000..e933450 --- /dev/null +++ b/scripts/test_package_scripts_manifest.js @@ -0,0 +1,13 @@ +const assert = require('assert'); +const { classifyScript } = require('./package_scripts_manifest'); + +function main() { + assert.strictEqual(classifyScript('test:core', 'npm run -s test:ops'), 'test'); + assert.strictEqual(classifyScript('ops:dashboard', 'node scripts/ops_dashboard.js'), 'ops'); + assert.strictEqual(classifyScript('runtime:daily:status', 'node scripts/runtime_bot.js daily status'), 'runtime'); + assert.strictEqual(classifyScript('cron:daily:digest', 'node scripts/daily_telegram_digest.js'), 'cron'); + assert.strictEqual(classifyScript('anki:backfill:dry', 'node scripts/anki_backfill_quality.js'), 'anki'); + console.log('test_package_scripts_manifest: ok'); +} + +main(); diff --git a/scripts/test_personal_job_pipeline.js b/scripts/test_personal_job_pipeline.js new file mode 100644 index 0000000..fd3a7d3 --- /dev/null +++ b/scripts/test_personal_job_pipeline.js @@ -0,0 +1,151 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { handleJobPipelineCommand, parseCommand } = require('./personal_job_pipeline'); + +function makeTempDb() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'personal-job-pipeline-')); + return { + dir, + dbPath: path.join(dir, 'personal.sqlite'), + }; +} + +async function main() { + const { dir, dbPath } = makeTempDb(); + try { + const parsed = parseCommand('지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://jobs.example/acme 스택=Node Tokyo priority=2'); + assert.strictEqual(parsed.action, 'add'); + assert.strictEqual(parsed.company.name, 'Acme'); + assert.strictEqual(parsed.opportunity.title, 'Backend Engineer'); + + const add = await handleJobPipelineCommand( + '지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://jobs.example/acme 스택=Node.js 위치=Tokyo fit_score=4 interest_score=5 priority=2', + { dbPath }, + ); + assert.strictEqual(add.route, 'job'); + assert.strictEqual(add.success, true); + assert.strictEqual(add.action, 'add'); + assert.ok(add.entityId); + assert.ok(/단계:\s*관심 목록/.test(add.telegramReply)); + + const duplicate = await handleJobPipelineCommand( + '지원처 추가 회사명=Acme 포지션=Backend Engineer 링크=https://jobs.example/acme 스택=Node.js 위치=Tokyo fit_score=4 interest_score=5 priority=2', + { dbPath }, + ); + assert.strictEqual(duplicate.success, true); + assert.strictEqual(duplicate.action, 'duplicate'); + + const stage = await handleJobPipelineCommand('Acme 현재 단계 interview_1로 변경', { dbPath }); + assert.strictEqual(stage.success, true); + assert.strictEqual(stage.action, 'stage'); + assert.strictEqual(stage.result.detail.current_stage, 'interview_1'); + assert.ok(/1차 면접/.test(stage.telegramReply)); + assert.ok(!stage.telegramReply.includes('interview_1')); + + const naturalInterview = await handleJobPipelineCommand('Acme 서류 통과해서 1차 면접 대기중이고 1차 면접일은 5월14일 19시야', { + dbPath, + now: '2026-04-29T00:00:00+09:00', + }); + assert.strictEqual(naturalInterview.success, true); + assert.strictEqual(naturalInterview.action, 'process_update'); + assert.strictEqual(naturalInterview.result.detail.current_stage, 'interview_1'); + assert.strictEqual(naturalInterview.result.nextAction.action.due_at, '2026-05-14 19:00'); + assert.ok(/서류 통과/.test(naturalInterview.result.note.note.content)); + + const bulk = await handleJobPipelineCommand('그리고 5월14일 16시에 eba테크 1차면접 있고 5월 1일 15:30에 datum studio 캐주얼면접 있어', { + dbPath, + now: '2026-04-29T00:00:00+09:00', + }); + assert.strictEqual(bulk.success, true); + assert.strictEqual(bulk.action, 'bulk_process_update'); + assert.strictEqual(bulk.result.length, 2); + assert.strictEqual(bulk.result[0].item.token, 'eba테크'); + assert.strictEqual(bulk.result[0].item.dueAt, '2026-05-14 16:00'); + assert.strictEqual(bulk.result[1].item.token, 'datum studio'); + assert.strictEqual(bulk.result[1].item.nextActionTitle, '캐주얼 면접'); + assert.strictEqual(bulk.result[1].item.dueAt, '2026-05-01 15:30'); + + const members = await handleJobPipelineCommand('5월 1일 17:30에 멤버스 캐주얼면담 있고 여기는 비즈리치로 연락중', { + dbPath, + now: '2026-04-29T00:00:00+09:00', + }); + assert.strictEqual(members.success, true); + assert.strictEqual(members.action, 'process_update'); + assert.strictEqual(members.result.detail.company_name, '멤버스'); + assert.strictEqual(members.result.detail.current_stage, 'recruiter_contact'); + assert.strictEqual(members.result.nextAction.action.due_at, '2026-05-01 17:30'); + assert.ok(/비즈리치로 연락중/.test(members.result.note.note.content)); + + const note = await handleJobPipelineCommand('리크루터 메모 저장 Acme 다음 주에 답장 필요 #followup', { dbPath }); + assert.strictEqual(note.success, true); + assert.strictEqual(note.action, 'note'); + assert.ok(note.entityId); + + const contact = await handleJobPipelineCommand('연락처 추가 Acme Jane linkedin https://linkedin.example/jane', { dbPath }); + assert.strictEqual(contact.success, true); + assert.strictEqual(contact.action, 'contact'); + assert.ok(contact.entityId); + + const next = await handleJobPipelineCommand('Acme 다음액션=포트폴리오 보내기 마감=2026-05-03 priority=1', { dbPath }); + assert.strictEqual(next.success, true); + assert.strictEqual(next.action, 'next_action'); + assert.strictEqual(next.result.action.due_at, '2026-05-03'); + + const list = await handleJobPipelineCommand('목록', { dbPath }); + assert.strictEqual(list.success, true); + assert.strictEqual(list.action, 'list'); + assert.ok(Array.isArray(list.rows)); + assert.ok(list.rows.length >= 1); + assert.ok(/1차 면접/.test(list.telegramReply)); + assert.ok(/채용 담당자 연락 중/.test(list.telegramReply)); + assert.ok(!list.telegramReply.includes('interview_1')); + + const hiringStatus = await handleJobPipelineCommand('구인 현황', { dbPath }); + assert.strictEqual(hiringStatus.success, true); + assert.strictEqual(hiringStatus.action, 'list'); + assert.ok(/채용 담당자 연락 중/.test(hiringStatus.telegramReply)); + + const pending = await handleJobPipelineCommand('이번 주 팔로업 필요한 회사 보여줘', { + dbPath, + now: '2026-04-29T00:00:00+09:00', + }); + assert.strictEqual(pending.success, true); + assert.strictEqual(pending.action, 'pending'); + assert.ok(pending.rows.some((row) => row.company_name === 'Acme')); + + const search = await handleJobPipelineCommand('검색 backend', { dbPath }); + assert.strictEqual(search.success, true); + assert.strictEqual(search.action, 'search'); + assert.ok(search.rows.some((row) => row.company_name === 'Acme')); + + const detail = await handleJobPipelineCommand('상세 Acme', { dbPath }); + assert.strictEqual(detail.success, true); + assert.strictEqual(detail.action, 'detail'); + assert.strictEqual(detail.detail.detail.company_name, 'Acme'); + assert.ok(detail.detail.timeline.length >= 3); + assert.ok(/단계:\s*1차 면접/.test(detail.telegramReply)); + assert.ok(/상태:\s*진행 중/.test(detail.telegramReply)); + assert.ok(!detail.telegramReply.includes('interview_1')); + + const weekly = await handleJobPipelineCommand('주간요약', { + dbPath, + now: '2026-04-29T00:00:00+09:00', + }); + assert.strictEqual(weekly.success, true); + assert.strictEqual(weekly.action, 'weekly_summary'); + assert.ok(weekly.summary.byStage.length >= 1); + assert.ok(!weekly.telegramReply.includes('interview_1')); + + console.log('test_personal_job_pipeline: ok'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test_run_changed_tests.js b/scripts/test_run_changed_tests.js new file mode 100644 index 0000000..519ac7b --- /dev/null +++ b/scripts/test_run_changed_tests.js @@ -0,0 +1,25 @@ +const assert = require('assert'); +const { planChangedTests } = require('./run_changed_tests'); + +function main() { + const plan = planChangedTests([ + 'scripts/anki_connect.js', + 'scripts/ops_dashboard.js', + 'package.json', + ]); + assert.deepStrictEqual(plan.commands, [ + 'npm run -s test:scope:anki', + 'npm run -s test:ops', + 'npm run -s check:scripts', + ]); + + const fallback = planChangedTests(['README.md']); + assert.deepStrictEqual(fallback.commands, ['npm run -s test:smoke']); + + const empty = planChangedTests([]); + assert.deepStrictEqual(empty.commands, []); + + console.log('test_run_changed_tests: ok'); +} + +main();