elanous 0.2.18 → 0.2.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/{1307-30dff22c9e1f3073.js → 1307-77fad80cf54477c6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/1612-f60c0c93ec773f81.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2116-6429c188eb846ab8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/224-e7be8f762fe74986.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2489-ff55a35b202c0f94.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2549-89c518a4c8ed4d00.js +1 -0
- package/apps/pwa/out/_next/static/chunks/3857-995ea9f5ef33d71e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/4144-fad095b48a1862ee.js +1 -0
- package/apps/pwa/out/_next/static/chunks/5755-ea7ed88eb956f47b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/5977-94ed6f78e9814b2b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7748-6e51bfe927019f5c.js +25 -0
- package/apps/pwa/out/_next/static/chunks/8401-35e94e9091f5eb8d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9247-c1753a059e7bac08.js +1 -0
- package/apps/pwa/out/_next/static/chunks/a9f2b54d-33c0689b70ed8cf3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/{page-d92f7ee3994e84c7.js → page-c855c24de7db74fe.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-4733c967b56a4277.js → page-85b316bb4235980b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/{page-d22fd849cc22f104.js → page-9b2b26a33cb2cd42.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-bb742f995c6769e7.js → page-c9955fde67b1bcb5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-02b073a2ac793b83.js → page-7a852956c74015a3.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-c5a5c621be0e04ae.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-8a9b3594bc23289b.js → page-4ba4abe096b6c2a8.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-575d4a8b4f7becb2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/{page-308111abadf35670.js → page-f5d56ff5f45ad164.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/decisions/page-2260e9eef0357421.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-5d97b2901e0d1ea6.js → page-7c058069c5e7fa63.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-dc5da0b8a2edb8a4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-8c3d8e63ae2e1c98.js → page-466bf0ee16ad1432.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-9b72a07d424701c6.js → page-a23bc71713bcc08f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-ed4a6f799f005d09.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-a572f58cd3ca5864.js → page-9cd565005b93aca6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-55f0f40c6db95f15.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/{page-be5ea965a07fbc41.js → page-474cb5888ea0e8f3.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-35bfec2e631d3eae.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/{page-fa244bd146a00ae8.js → page-9ca21f9ad3208a61.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-44d56d6391965a6a.js → page-f9ccb1b1adbea14d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-83a700d4c754d802.js → page-ec102c9b8b691e37.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-7b3170f374f77b9d.js → not-found-a9c2cfa8dda922a5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-6da35144878feb6d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-f6055495e9ed7ea8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-25204694fa9984a5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-a60e035f151d84d3.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-189c587f36874538.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-c56634bde705a65d.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1467047104141297.js → page-db03ae3589a81c9e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-4a6463b97f0ad94f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-102310930791197a.js → page-75bffecc9aa1004e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-a8d2fe0c669e0de4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-6ed0b73bae8351d8.js → page-31d5b875760bb62d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-c26fa10aac6c21c4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-43336055451f6e37.js → page-68793c6e10331d66.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/{page-3cc47dca0922ab9a.js → page-46d5201d2207a511.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/{page-a77ee355a2304ff0.js → page-d81c07a7b4f618ae.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-b17cd46cf0e9b8cf.js → page-be1c4e8697f22360.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-cf0a4b4abead71ea.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/today/page-a4717e2c4bf7a28f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-7bf54c9bb9274af8.js → page-0fd895fab9b62c1b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-8b6c1454a8e8f306.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-c6e4364d9de8f717.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-0acc8e198f7b0dfb.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-134da468538baf48.js → page-34882c84f84021c5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-0506a1e0e4bdb2f5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{webpack-c2782b330447ffb6.js → webpack-c260571513bf43c2.js} +1 -1
- package/apps/pwa/out/_next/static/css/{e99e3cff94ced78a.css → 599369d853c61df7.css} +1 -1
- package/apps/pwa/out/_next/static/css/c23d959d3ee15e57.css +1 -0
- package/apps/pwa/out/_next/static/css/d3427996da11e480.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +14 -14
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +14 -14
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +14 -14
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +14 -14
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +14 -14
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +14 -14
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +14 -14
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +14 -14
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +14 -14
- package/apps/pwa/out/decisions/index.html +1 -1
- package/apps/pwa/out/decisions/index.txt +14 -14
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +14 -14
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +16 -15
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +14 -14
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +14 -14
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +14 -14
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +16 -15
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +14 -14
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +14 -14
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +24 -23
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +14 -14
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +14 -14
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +14 -14
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +14 -14
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +14 -14
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +14 -14
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +14 -14
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +14 -14
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +15 -15
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +14 -14
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +14 -14
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +14 -14
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +14 -14
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +14 -14
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +14 -14
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +14 -14
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +14 -14
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +14 -14
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +14 -14
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +14 -14
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +14 -14
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +14 -14
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +14 -14
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +14 -14
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +14 -14
- package/graphs/demo/editor-recipes.yaml +22 -0
- package/graphs/ops/guardian.yaml +26 -0
- package/graphs/ops/recipes.yaml +3 -0
- package/graphs/retro/daily.yaml +25 -0
- package/graphs/retro/recipes.yaml +24 -0
- package/package.json +1 -1
- package/scripts/ask-marker-check.ts +3 -1
- package/scripts/coord-channel-watch.sh +2 -2
- package/scripts/discord-seat-probe.ts +3 -1
- package/scripts/land-global-checks.ts +89 -0
- package/scripts/lib/pch-regress-map.ts +24 -0
- package/scripts/morning-report.ts +18 -4
- package/scripts/pch-regress.ts +88 -0
- package/scripts/release-loop/gate-diff.ts +18 -0
- package/scripts/release-loop/gate-node.ts +628 -65
- package/scripts/release-loop/gate-partial.ts +106 -0
- package/scripts/release-loop/gate-timing.ts +229 -0
- package/scripts/release-loop/preflight.ts +6 -4
- package/scripts/release-loop/publish-window.ts +141 -0
- package/scripts/release-loop/release-docs.ts +1 -4
- package/scripts/release-loop/release-universe.ts +38 -0
- package/scripts/release-loop/resume-release.ts +67 -5
- package/scripts/release-loop/shard-plan.ts +65 -0
- package/scripts/release-loop/unattended-release.ts +12 -2
- package/scripts/release-story/draft.ts +5 -1
- package/scripts/retro/shadow-compare.ts +71 -0
- package/scripts/rhythm/daily.ts +6 -1
- package/scripts/video/scene-table.ts +159 -0
- package/src/acp/core-turn-bridge.ts +39 -2
- package/src/agent-mission/driver.ts +34 -6
- package/src/autopilot/build/llm-conflict-merge.ts +132 -7
- package/src/autopilot/build/merge-intent.ts +135 -7
- package/src/away/presence.ts +40 -0
- package/src/away/release-watch.ts +272 -0
- package/src/chat/index.ts +2 -1
- package/src/chat/short-question.ts +3 -40
- package/src/cli/away-cli.ts +68 -0
- package/src/cli/config-drift.ts +513 -0
- package/src/cli/guardian-cli.ts +49 -0
- package/src/cli/intake-cli.ts +13 -7
- package/src/cli/log-event-names.ts +5 -0
- package/src/cli/pod-cli.ts +5 -1
- package/src/cli/pr-cli.ts +20 -0
- package/src/cli/pty-drive-cli.ts +14 -3
- package/src/cli/release-cli.ts +52 -12
- package/src/cli/retro-cli.ts +78 -0
- package/src/cli/setup-cli.ts +31 -1
- package/src/cli/tasks-cli.ts +193 -14
- package/src/context-bus/context-now-public.ts +1 -1
- package/src/context-bus/context-now-surfaces.ts +16 -6
- package/src/context-bus/context-now.ts +97 -3
- package/src/core-turn/short-question.ts +100 -0
- package/src/dashboard/dashboard-status-lines.ts +24 -4
- package/src/dashboard/index.ts +50 -5
- package/src/dashboard/input/slash-executor.ts +6 -2
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +68 -40
- package/src/dashboard/slash-runtime/decide-slash.ts +60 -0
- package/src/dashboard/slash-runtime/help-from-registry.ts +62 -23
- package/src/dashboard/slash-runtime/help-groups.ts +44 -0
- package/src/dashboard/slash-runtime/model-choices.ts +125 -0
- package/src/domains/guardian-tick.ts +138 -0
- package/src/domains/outbound-alert.ts +44 -13
- package/src/domains/outbound-log-probe.ts +6 -1
- package/src/domains/schedule-registry.ts +23 -0
- package/src/domains/telegram-kind-route.ts +39 -0
- package/src/domains/watch-default-scheduler.ts +95 -0
- package/src/esc-abort-gate.ts +44 -0
- package/src/graph-runner/graph-run-api.ts +205 -0
- package/src/graph-runner/runner.ts +36 -6
- package/src/harness/harness-cli-command.ts +420 -51
- package/src/harness/harness-queue.ts +315 -25
- package/src/harness/harness-salvage-cli.ts +140 -0
- package/src/harness/harness-worktree-add.ts +68 -0
- package/src/harness/ledger-write-guard.ts +6 -16
- package/src/hq/fence-outcomes.ts +63 -0
- package/src/hq/fence-wrapper.ts +33 -0
- package/src/hq/hq.ts +17 -2
- package/src/index.ts +118 -6
- package/src/instance/test-write-guard-spy-first.fixture.ts +13 -0
- package/src/instance/test-write-guard.ts +200 -0
- package/src/intake-plane/digest.ts +39 -15
- package/src/intake-plane/harness-target.ts +4 -3
- package/src/intake-plane/intake-sources.ts +1 -0
- package/src/intake-plane/items.ts +1 -0
- package/src/loops/orchestrator/degradation.ts +57 -0
- package/src/loops/orchestrator/finish-rate.ts +33 -3
- package/src/loops/orchestrator/tick.ts +166 -45
- package/src/maturity/feature-maturity.ts +6 -4
- package/src/maturity/tui-slash-maturity.ts +1 -0
- package/src/mss/logging/log-store.ts +3 -0
- package/src/nexus/api/graph-kinds.ts +10 -4
- package/src/nexus/api/graphs-api.ts +178 -6
- package/src/nexus/api/http-server.ts +54 -8
- package/src/nexus/api/loop-edges.ts +292 -17
- package/src/nexus/api/ops-api.ts +112 -29
- package/src/nexus/api/ops-board-route.ts +48 -0
- package/src/nexus/index.ts +7 -0
- package/src/onboarding/entry-hints.ts +5 -0
- package/src/onboarding.ts +24 -6
- package/src/release-loop/checklist.ts +2 -0
- package/src/release-loop/feature-store.ts +68 -18
- package/src/release-loop/gate-shards.ts +158 -0
- package/src/release-loop/light-rc.ts +173 -0
- package/src/release-loop/placement.ts +51 -17
- package/src/release-loop/release-schedule.ts +6 -2
- package/src/release-loop/rubric.ts +57 -1
- package/src/retro/daily.ts +372 -0
- package/src/seat-loop/op-decide.ts +173 -0
- package/src/seat-loop/seat-loop.ts +60 -3
- package/src/seat-loop/seat-report.ts +58 -3
- package/src/self-dev/ask-launch-flow.ts +5 -4
- package/src/self-dev/ask-launch-io.ts +49 -1
- package/src/self-dev/ask-path-candidates.ts +40 -2
- package/src/self-dev/dev-cli.ts +36 -0
- package/src/self-dev/draft-sweep.ts +371 -16
- package/src/self-dev/draft-triage-rules.ts +19 -4
- package/src/self-dev/orchestrate.ts +1 -0
- package/src/self-dev/pr-preview.ts +247 -0
- package/src/self-dev/shard-run.ts +23 -7
- package/src/self-implement/blocked-draft-policy.ts +101 -0
- package/src/self-implement/execution-clarification.ts +11 -0
- package/src/self-implement/gate-cli.ts +29 -8
- package/src/self-implement/gate-env-retry.ts +83 -0
- package/src/self-implement/goal-author-clarification.ts +88 -13
- package/src/self-implement/goal-clarification-escalation.ts +2 -0
- package/src/self-implement/orchestrator.ts +220 -6
- package/src/self-implement/run-stop.ts +123 -0
- package/src/self-implement/run-ttl.ts +258 -0
- package/src/self-implement/salvage-branches.ts +254 -0
- package/src/self-implement/salvage-retention.ts +123 -0
- package/src/self-implement/seams.ts +4 -0
- package/src/self-implement/self-implement-cli.ts +2 -0
- package/src/self-implement/sibling-resync.ts +223 -0
- package/src/self-implement/stop-autoheal.ts +204 -0
- package/src/task-agent/actions.ts +63 -2
- package/src/task-agent/board.ts +132 -0
- package/src/task-agent/card-evidence.ts +292 -0
- package/src/task-agent/completion-evidence.ts +126 -0
- package/src/task-agent/judge.ts +25 -0
- package/src/task-agent/live-moves.ts +276 -0
- package/src/task-agent/mission.ts +33 -7
- package/src/task-agent/shadow.ts +192 -15
- package/src/task-agent/task-hand.ts +124 -17
- package/src/task-orchestrator/surfaces/pod-command-job.ts +82 -8
- package/src/task-orchestrator/surfaces/pod-failure-reason.ts +4 -0
- package/src/task-orchestrator/surfaces/pod-install-slots.ts +89 -0
- package/src/task-orchestrator/surfaces/pod-lease.ts +233 -21
- package/src/task-orchestrator/surfaces/pod-pool.ts +155 -34
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +21 -5
- package/src/telegram-away-command.ts +38 -0
- package/src/telegram-commands.ts +52 -0
- package/src/telegram-report-log-probe.ts +21 -0
- package/src/telegram-report.ts +27 -3
- package/src/testing/real-cli-spawn.ts +106 -0
- package/src/tui-client/dashboard-session.ts +1 -0
- package/src/user-config.ts +190 -5
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/224-798346143007ff65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/3857-4f936cdb30322aa6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4144-a0041c8dc3be57ac.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4554-5761612531d0146b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5977-a11f37254196b659.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5994-ae92c0de046ab9ba.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8401-8615a660c84cdb03.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8868-10a3a4440fb0ccd8.js +0 -25
- package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/a9f2b54d-7865aba0f4d2ee49.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-60c3da708f9a0ccc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-a0eb02638b1e6f31.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/decisions/page-6eaf7bac37830c21.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/page-c39554c321576982.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-bd5d586429167fea.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-59b7834d497c2255.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-c130b63862437814.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-5f23d0c66a3b4793.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-a3c58f5c72a5f3f9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-a2c4625596f8d69c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-0e9099531d239f11.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-e8e1ecaea7cd71d2.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-a1b7ffed710a93e0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-2da6f9b8a38c6978.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-f331e38fefc00393.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-44d5f767491692bf.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-61553851aa606146.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-a1f585e154e61c59.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-5150ef17b5383a57.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-b9d8ce70792c1672.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a5a3de231f2cc7d5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-31ac4afa2dcd915c.js +0 -1
- package/apps/pwa/out/_next/static/css/7fa358fec7da7c47.css +0 -1
- /package/apps/pwa/out/_next/static/{P6YTVgzuZz5oTZhY4Din_ → Bam1YziK2iH9bN4K0utGk}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{P6YTVgzuZz5oTZhY4Din_ → Bam1YziK2iH9bN4K0utGk}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// TUI-SLASH-DECIDE-NOW C · 2026-10-07 — `/model` offered only code names (terra·sol·luna…), so a first
|
|
2
|
+
// user had nothing to choose by (F14). Three human words now sit in front: 빠름·보통·깊음 resolve to
|
|
3
|
+
// the CURRENT provider's budget·better·best tiers via `lookupLlmTierSpec` (no hard-coded model ids).
|
|
4
|
+
// The seven code names keep going to the same model and provider as before.
|
|
5
|
+
// A tier is «model ⊕ reasoning level» — on several ladders better and best share a model and differ only
|
|
6
|
+
// in reasoning (codex gpt-6-sol medium/high · opus medium/medium), so a human word also carries the tier's
|
|
7
|
+
// reasoning level; otherwise 보통 and 깊음 would be the same choice. Code names leave reasoning alone.
|
|
8
|
+
import type { LLMProviderName, ReasoningLevel } from '../../user-config.js';
|
|
9
|
+
import { lookupLlmTierSpec } from '../../model-tier/index.js';
|
|
10
|
+
import type { ModelTier } from '../../model-tier/types.js';
|
|
11
|
+
|
|
12
|
+
export interface ModelTarget {
|
|
13
|
+
readonly model: string;
|
|
14
|
+
readonly provider: LLMProviderName;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 명시 target 맵(id + provider) — catalog/prefix 추론 대신 확정값. 핵심:
|
|
18
|
+
// Codex 계열(gpt-5.5/5.6-sol/terra/luna)은 gpt- prefix 라 자동 추론은
|
|
19
|
+
// 'openai'(Chat Completions)로 오분류하나, 실제론 Responses API 필수라
|
|
20
|
+
// provider=openai-codex 여야 한다(omni-crawl 2026-07-17 확인). GPT(openai·
|
|
21
|
+
// Chat Completions)는 노출하지 않는다(대표 지시 — 혼란 방지·codex만).
|
|
22
|
+
// 비-OpenAI fallback 은 claude(anthropic)·grok.
|
|
23
|
+
export const MODEL_TARGETS: Readonly<Record<string, ModelTarget>> = {
|
|
24
|
+
codex: { model: 'gpt-5.5', provider: 'openai-codex' },
|
|
25
|
+
// 🩸 2026-09-23 — GPT-6 에는 terra 가 없다(결정). 별칭은 남기되 codex 사다리 better 칸(sol 한 칸 아래 자리)을 가리킨다.
|
|
26
|
+
terra: { model: lookupLlmTierSpec('openai-codex', 'better').model, provider: 'openai-codex' },
|
|
27
|
+
sol: { model: lookupLlmTierSpec('openai-codex', 'best').model, provider: 'openai-codex' },
|
|
28
|
+
luna: { model: lookupLlmTierSpec('openai-codex', 'budget').model, provider: 'openai-codex' },
|
|
29
|
+
opus: { model: 'claude-opus-4-8', provider: 'anthropic' },
|
|
30
|
+
sonnet: { model: 'claude-sonnet-5', provider: 'anthropic' },
|
|
31
|
+
// ⭐ 티어 표를 따른다(옆 luna 와 같은 형태) — 하드코딩이면 표를 바꿔도 «안 따라온다»(2026-08-18 대표 4.6 재편)
|
|
32
|
+
grok: { model: lookupLlmTierSpec('grok', 'best').model, provider: 'grok' },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const MODEL_CODE_NAMES: readonly string[] = Object.keys(MODEL_TARGETS);
|
|
36
|
+
|
|
37
|
+
export interface HumanModelChoice {
|
|
38
|
+
readonly word: '빠름' | '보통' | '깊음';
|
|
39
|
+
readonly english: 'fast' | 'normal' | 'deep';
|
|
40
|
+
readonly tier: ModelTier;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const HUMAN_MODEL_CHOICES: readonly HumanModelChoice[] = [
|
|
44
|
+
{ word: '빠름', english: 'fast', tier: 'budget' },
|
|
45
|
+
{ word: '보통', english: 'normal', tier: 'better' },
|
|
46
|
+
{ word: '깊음', english: 'deep', tier: 'best' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export const HUMAN_MODEL_WORDS: readonly string[] = HUMAN_MODEL_CHOICES.map(({ word }) => word);
|
|
50
|
+
|
|
51
|
+
export type ModelChoiceResolution =
|
|
52
|
+
| { readonly kind: 'human'; readonly word: HumanModelChoice['word']; readonly tier: ModelTier; readonly target: ModelTarget; readonly reasoningLevel?: ReasoningLevel }
|
|
53
|
+
| { readonly kind: 'code'; readonly name: string; readonly target: ModelTarget }
|
|
54
|
+
/** A human word while the provider is `auto` — there is no single ladder to read. */
|
|
55
|
+
| { readonly kind: 'needs-provider'; readonly word: HumanModelChoice['word'] }
|
|
56
|
+
| { readonly kind: 'unknown'; readonly arg: string };
|
|
57
|
+
|
|
58
|
+
/** Resolve a `/model` argument against the provider that is active right now. */
|
|
59
|
+
export function resolveModelChoice(rawArg: string, currentProvider: LLMProviderName): ModelChoiceResolution {
|
|
60
|
+
const arg = rawArg.trim().toLowerCase();
|
|
61
|
+
const human = HUMAN_MODEL_CHOICES.find(({ word, english }) => arg === word || arg === english);
|
|
62
|
+
if (human) {
|
|
63
|
+
if (currentProvider === 'auto') return { kind: 'needs-provider', word: human.word };
|
|
64
|
+
const spec = lookupLlmTierSpec(currentProvider, human.tier);
|
|
65
|
+
return {
|
|
66
|
+
kind: 'human', word: human.word, tier: human.tier, target: { model: spec.model, provider: currentProvider },
|
|
67
|
+
...(spec.reasoningLevel ? { reasoningLevel: spec.reasoningLevel } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const target = MODEL_TARGETS[arg];
|
|
71
|
+
if (target) return { kind: 'code', name: arg, target };
|
|
72
|
+
return { kind: 'unknown', arg };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The human word for (`model`, `reasoning`) on `provider`: an exact tier match first; without a reasoning
|
|
77
|
+
* level (or no exact match) only a model that names a single tier counts — an ambiguous model gets no word.
|
|
78
|
+
*/
|
|
79
|
+
export function humanWordForModel(
|
|
80
|
+
provider: LLMProviderName,
|
|
81
|
+
model: string | undefined | null,
|
|
82
|
+
reasoning?: ReasoningLevel,
|
|
83
|
+
): HumanModelChoice['word'] | undefined {
|
|
84
|
+
if (!model || provider === 'auto') return undefined;
|
|
85
|
+
const sameModel = HUMAN_MODEL_CHOICES.filter(({ tier }) => lookupLlmTierSpec(provider, tier).model === model);
|
|
86
|
+
if (reasoning) {
|
|
87
|
+
const exact = sameModel.find(({ tier }) => {
|
|
88
|
+
const level = lookupLlmTierSpec(provider, tier).reasoningLevel;
|
|
89
|
+
return level === undefined || level === reasoning;
|
|
90
|
+
});
|
|
91
|
+
if (exact) return exact.word;
|
|
92
|
+
}
|
|
93
|
+
return sameModel.length === 1 ? sameModel[0]!.word : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function currentLabel(provider: LLMProviderName, model: string | undefined | null, reasoning?: ReasoningLevel): string {
|
|
97
|
+
return humanWordForModel(provider, model, reasoning) ?? (model?.trim() || '기본 모델');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `/model` with no argument: the current choice, then how to choose. The second line is muted by the caller. */
|
|
101
|
+
export function modelOverviewLines(
|
|
102
|
+
provider: LLMProviderName,
|
|
103
|
+
model: string | undefined | null,
|
|
104
|
+
reasoning?: ReasoningLevel,
|
|
105
|
+
): [string, string] {
|
|
106
|
+
const word = humanWordForModel(provider, model, reasoning);
|
|
107
|
+
const now = word ? `${word} (${model})` : (model?.trim() || '기본 모델');
|
|
108
|
+
return [
|
|
109
|
+
`지금: ${now} · 고르기: /model ${HUMAN_MODEL_WORDS.join(' | ')}`,
|
|
110
|
+
` 코드명도 됨: ${MODEL_CODE_NAMES.join(' · ')} · provider ${provider} · 생각 깊이: /reasoning`,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Confirmation after a switch: «보통 → 깊음 (<model>)». */
|
|
115
|
+
export function modelChangeLine(
|
|
116
|
+
before: { provider: LLMProviderName; model: string | undefined | null; reasoning?: ReasoningLevel },
|
|
117
|
+
resolution: Extract<ModelChoiceResolution, { kind: 'human' | 'code' }>,
|
|
118
|
+
): string {
|
|
119
|
+
const after = resolution.kind === 'human'
|
|
120
|
+
? resolution.word
|
|
121
|
+
: humanWordForModel(resolution.target.provider, resolution.target.model) ?? resolution.name;
|
|
122
|
+
const reasoningNote = resolution.kind === 'human' && resolution.reasoningLevel ? ` · 생각 ${resolution.reasoningLevel}` : '';
|
|
123
|
+
const providerNote = resolution.target.provider === before.provider ? '' : ` · provider ${resolution.target.provider}`;
|
|
124
|
+
return `모델: ${currentLabel(before.provider, before.model, before.reasoning)} → ${after} (${resolution.target.model}${reasoningNote})${providerNote}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { debug } from '../debug/log.js';
|
|
4
|
+
import { calendarFields, resolveTimeZone } from '../time/format.js';
|
|
5
|
+
import { cronMatches } from './cron-match.js';
|
|
6
|
+
import { listScheduleRuns } from './schedule-runs.js';
|
|
7
|
+
import { listSchedules, openSchedulesDb, type ScheduleRow } from './schedule-registry.js';
|
|
8
|
+
import type { SpawnOutcome } from './schedule-runner.js';
|
|
9
|
+
|
|
10
|
+
export interface GuardianPlan {
|
|
11
|
+
jobs: readonly (Pick<ScheduleRow, 'id' | 'cron' | 'enabled' | 'source' | 'run_via'> & Partial<Pick<ScheduleRow, 'command'>>)[];
|
|
12
|
+
timeZone?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GuardianDeps {
|
|
16
|
+
config?: { guardian?: { mode?: 'shadow' | 'live' } };
|
|
17
|
+
/** Inject a cron-run-compatible executor in tests; shadow never calls it. */
|
|
18
|
+
execute?: (command: string) => Promise<SpawnOutcome>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shellQuote(value: string): string {
|
|
22
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function matchedJobs(plan: GuardianPlan): GuardianPlan['jobs'] {
|
|
26
|
+
return plan.jobs.filter(job => job.source === 'crontab' && job.run_via === 'crontab' && !!job.enabled && !!job.cron);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The wrapper prefix a live run may reuse as-is: `<bun> <…/>cron-run.ts --schedule-id <id> …` as the command's own
|
|
30
|
+
* first program (cron-run honours --schedule-id only as argv[0]). Fail-closed: any other mention of cron-run.ts —
|
|
31
|
+
* e.g. inside a `sh -c` body, behind `cd … &&`, or with shell control operators — is ambiguous and refused, so a run
|
|
32
|
+
* can never be attributed to the wrong (or no) schedule id. Unwrapped commands are wrapped with this job's id. */
|
|
33
|
+
function liveCommand(jobId: string, raw: string): string {
|
|
34
|
+
if (!/cron-run\.ts/.test(raw)) {
|
|
35
|
+
return `${shellQuote(process.execPath)} ${shellQuote(fileURLToPath(new URL('../../scripts/cron-run.ts', import.meta.url)))} --schedule-id ${shellQuote(jobId)} --shell /bin/sh -c ${shellQuote(raw)}`;
|
|
36
|
+
}
|
|
37
|
+
// First program must be bun itself (bare `bun`, a path ending in /bun, or this process's executable).
|
|
38
|
+
const prefix = /^\s*(?:'(?:[^']*\/)?bun'|(?:[^\s'"]*\/)?bun)\s+(?:'[^']*\/)?(?:[^\s'"]*\/)?cron-run\.ts'?\s+--schedule-id\s+(?:'([^']*)'|([^\s'"]+))(?:\s|$)/.exec(raw);
|
|
39
|
+
const declaredId = prefix ? (prefix[1] ?? prefix[2]) : undefined;
|
|
40
|
+
const controls = /[;&|`\n]|\$\(/.test(raw);
|
|
41
|
+
if (declaredId !== jobId || controls || (raw.match(/cron-run\.ts/g) ?? []).length !== 1) {
|
|
42
|
+
throw new Error(`guardian live job ${jobId}: cron-run --schedule-id is ${declaredId ?? 'missing'}${controls ? ' (shell control operators)' : ''} — refusing misattributed run`);
|
|
43
|
+
}
|
|
44
|
+
return raw;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One five-minute guardian observation. No schedule state or crontab is mutated in shadow mode. */
|
|
48
|
+
export async function guardianTick(now: Date, plan: GuardianPlan, deps: GuardianDeps = {}): Promise<{ mode: 'shadow' | 'live'; wouldRun: string[]; executed: number }> {
|
|
49
|
+
if (!Number.isFinite(now.getTime())) throw new RangeError('invalid tick time');
|
|
50
|
+
const mode = deps.config?.guardian?.mode === 'live' ? 'live' : 'shadow';
|
|
51
|
+
const timeZone = plan.timeZone ?? resolveTimeZone().timeZone;
|
|
52
|
+
const wouldRun: string[] = [];
|
|
53
|
+
let executed = 0;
|
|
54
|
+
for (const job of matchedJobs(plan)) {
|
|
55
|
+
if (!cronMatches(job.cron!, now, { timeZone })) continue;
|
|
56
|
+
wouldRun.push(job.id);
|
|
57
|
+
debug.log('guardian.tick', 'would-run', { job: job.id, cron: job.cron });
|
|
58
|
+
if (mode === 'live') {
|
|
59
|
+
if (!job.command) throw new Error(`guardian live job has no command: ${job.id}`);
|
|
60
|
+
const command = liveCommand(job.id, job.command);
|
|
61
|
+
const execute = deps.execute ?? (await import('./schedule-runner.js')).defaultSpawnJob;
|
|
62
|
+
const outcome = await execute(command);
|
|
63
|
+
executed++;
|
|
64
|
+
if (outcome.code !== 0) throw new Error(`guardian job ${job.id} exited ${outcome.code}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { mode, wouldRun, executed };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GuardianDayDeps {
|
|
71
|
+
db: Database;
|
|
72
|
+
plan: GuardianPlan;
|
|
73
|
+
/** Optional measured alert counts, keyed by stable schedule id. Absent means unmeasured, not zero. */
|
|
74
|
+
alertsActual?: (jobId: string, day: string) => number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface GuardianComparison {
|
|
78
|
+
day: string;
|
|
79
|
+
timeZone: string;
|
|
80
|
+
jobs: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
|
|
81
|
+
diff: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
|
|
82
|
+
same: boolean;
|
|
83
|
+
/** Jobs whose measured alert count differs from would. Empty when alerts are unmeasured. */
|
|
84
|
+
alertDiff: Array<{ job: string; would: number; actual: number; alertsActual: number | null }>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Count the 5-minute shadow ticks for a calendar day in the scheduler's time zone — using the CURRENT plan
|
|
88
|
+
* (today's cron/enabled), so a past day is judged against the present schedule, not the one in force that day —
|
|
89
|
+
* then compare actual fire history by stable id and instant (never by job name). */
|
|
90
|
+
export function guardianShadowDay(day: string, deps?: GuardianDayDeps): GuardianComparison {
|
|
91
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !Number.isFinite(Date.parse(`${day}T00:00:00Z`))
|
|
92
|
+
|| new Date(`${day}T00:00:00Z`).toISOString().slice(0, 10) !== day) {
|
|
93
|
+
throw new RangeError('day must be a valid YYYY-MM-DD');
|
|
94
|
+
}
|
|
95
|
+
if (!deps) {
|
|
96
|
+
const db = openSchedulesDb();
|
|
97
|
+
try { return guardianShadowDay(day, { db, plan: { jobs: listSchedules(db) } }); }
|
|
98
|
+
finally { db.close(); }
|
|
99
|
+
}
|
|
100
|
+
const timeZone = deps.plan.timeZone ?? resolveTimeZone().timeZone;
|
|
101
|
+
const jobs = matchedJobs(deps.plan);
|
|
102
|
+
const counts = new Map(jobs.map(job => [job.id, 0]));
|
|
103
|
+
const date = Date.parse(`${day}T00:00:00Z`);
|
|
104
|
+
// Bracket every possible IANA civil-day offset, including daylight-saving transitions.
|
|
105
|
+
for (let ms = date - 14 * 3600_000; ms < date + 38 * 3600_000; ms += 5 * 60_000) {
|
|
106
|
+
const at = new Date(ms);
|
|
107
|
+
const fields = calendarFields(at, { timeZone });
|
|
108
|
+
if (`${fields.year}-${String(fields.month).padStart(2, '0')}-${String(fields.day).padStart(2, '0')}` !== day) continue;
|
|
109
|
+
for (const job of jobs) {
|
|
110
|
+
if (cronMatches(job.cron!, at, { timeZone })) counts.set(job.id, counts.get(job.id)! + 1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const rows = jobs.map(job => {
|
|
114
|
+
// schedule_runs retains only the latest 500 fires per id: older days can be incomplete.
|
|
115
|
+
const runs = listScheduleRuns(deps.db, job.id, { limit: 500 });
|
|
116
|
+
const actual = runs.filter(run => {
|
|
117
|
+
const fields = calendarFields(new Date(run.fired_at), { timeZone });
|
|
118
|
+
return `${fields.year}-${String(fields.month).padStart(2, '0')}-${String(fields.day).padStart(2, '0')}` === day;
|
|
119
|
+
}).length;
|
|
120
|
+
if (runs.length === 500 && runs.length > 0) {
|
|
121
|
+
const oldest = calendarFields(new Date(runs[runs.length - 1]!.fired_at), { timeZone });
|
|
122
|
+
const oldestDay = `${oldest.year}-${String(oldest.month).padStart(2, '0')}-${String(oldest.day).padStart(2, '0')}`;
|
|
123
|
+
if (oldestDay >= day) throw new Error(`guardian comparison incomplete: schedule_runs history truncated for ${job.id} on ${day}`);
|
|
124
|
+
}
|
|
125
|
+
const alertsActual = deps.alertsActual?.(job.id, day) ?? null;
|
|
126
|
+
if (alertsActual !== null && (!Number.isSafeInteger(alertsActual) || alertsActual < 0)) {
|
|
127
|
+
throw new RangeError('alertsActual must be a non-negative integer or null');
|
|
128
|
+
}
|
|
129
|
+
return { job: job.id, would: counts.get(job.id)!, actual, alertsActual };
|
|
130
|
+
});
|
|
131
|
+
// `same` judges scheduled vs recorded fires only. Alert counts are a separate axis: unmeasured (null) by default,
|
|
132
|
+
// and a measured mismatch is reported in alertDiff without changing the fire verdict.
|
|
133
|
+
const diff = rows.filter(row => row.would !== row.actual);
|
|
134
|
+
const same = diff.length === 0;
|
|
135
|
+
const alertDiff = rows.filter(row => row.alertsActual !== null && row.would !== row.alertsActual);
|
|
136
|
+
debug.log('guardian.compare', 'day', { same, diff, alertDiff });
|
|
137
|
+
return { day, timeZone, jobs: rows, diff, same, alertDiff };
|
|
138
|
+
}
|
|
@@ -15,7 +15,7 @@ import { openSurfaceEventsDb, recordEvent } from './surface-events.js';
|
|
|
15
15
|
import { latestUserIntentTs } from '../user-intent/index.js';
|
|
16
16
|
import { getUserConfig, type UserConfig } from '../user-config.js';
|
|
17
17
|
import { resolveChannelBotToken } from '../channel-bot-token.js';
|
|
18
|
-
import { isOperationalKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './telegram-kind-route.js';
|
|
18
|
+
import { explainReportRoute, isOperationalKind, kindRouteTarget, logKindRouteFallback, mainHomeTarget } from './telegram-kind-route.js';
|
|
19
19
|
import { debug } from '../debug/log.js';
|
|
20
20
|
import { registerLogStoreSink, setLogInstanceName } from '../mss/logging/log-store.js';
|
|
21
21
|
import type { LogSink } from '../mss/logging/sink.js';
|
|
@@ -24,7 +24,8 @@ import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js'
|
|
|
24
24
|
// ★ origin 되돌림(대표 2026-07-12) — 미션 알림을 발신 채널(메인 Q&A 봇)로 되돌린다. type-only
|
|
25
25
|
// import 라 런타임 순환 없음(발송 로직은 이 파일에 self-contained). origin 없으면 report 폴백.
|
|
26
26
|
import type { MissionOrigin } from '../autopilot/mission-origin.js';
|
|
27
|
-
import { conatusPath } from './conatus-data-dir.js';
|
|
27
|
+
import { conatusDataDir, conatusPath } from './conatus-data-dir.js';
|
|
28
|
+
import { assertNoRealSendFromTest, assertNotTestWritingOps, isTestDouble } from '../instance/test-write-guard.js';
|
|
28
29
|
|
|
29
30
|
/** Explicit `ELANOUS_NEXUS_URL` wins. Otherwise ask the daemon endpoint resolver.
|
|
30
31
|
* A missing daemon is not a guessed port — the caller falls through to direct send. */
|
|
@@ -37,6 +38,8 @@ function nexusUrl(): string | null {
|
|
|
37
38
|
// /v1/outbound 로 POST — getElanousConfigDir() 치환은 prod 동치(~/.elanous) + --config-dir 정합.
|
|
38
39
|
const ACP_TOKEN_PATH = join(getElanousConfigDir(), 'acp-token');
|
|
39
40
|
const DEFERRED_PATH = conatusPath('outbound_deferred.jsonl');
|
|
41
|
+
// The real transport, captured before any test can spy on it — a pass-through spy is not a fake.
|
|
42
|
+
const REAL_EXEC_FILE_SYNC = childProcess.execFileSync;
|
|
40
43
|
|
|
41
44
|
// CLI/cron do not inherit the daemon's StoreSink. Register once before the first
|
|
42
45
|
// send, but never attach a second sink to a daemon which already owns one.
|
|
@@ -139,6 +142,7 @@ export function userRecentlyActive(now: Date = new Date()): boolean {
|
|
|
139
142
|
/** 야간 보류 적재 (jsonl append — 크론 동시 실행에 안전). origin 있으면 함께 적재 —
|
|
140
143
|
* 아침 flush 가 그 origin(발신 채널)으로 되돌려 발송(없으면 report 묶음). */
|
|
141
144
|
export function deferOutbound(text: string, kind: string, origin?: MissionOrigin | null, path = DEFERRED_PATH): boolean {
|
|
145
|
+
assertNotTestWritingOps(path, 'append the deferred-alert queue');
|
|
142
146
|
try {
|
|
143
147
|
mkdirSync(dirname(path), { recursive: true });
|
|
144
148
|
const rec = { ts: new Date().toISOString(), kind, text, ...(origin ? { origin } : {}) };
|
|
@@ -171,9 +175,12 @@ function resolveBotToken(botId?: string): string | null {
|
|
|
171
175
|
function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: string) => void): boolean {
|
|
172
176
|
if (origin.channel === 'discord') {
|
|
173
177
|
if (!origin.channelId) return false;
|
|
178
|
+
let token: string | undefined;
|
|
179
|
+
try { token = resolveChannelBotToken('discord', getUserConfig())?.token; } catch { return false; }
|
|
180
|
+
if (!token) return false;
|
|
181
|
+
// Outside the swallowing try below: a refused test send must surface, not read as «delivery failed».
|
|
182
|
+
assertTestSendFaked('send a real Discord message');
|
|
174
183
|
try {
|
|
175
|
-
const token = resolveChannelBotToken('discord', getUserConfig())?.token;
|
|
176
|
-
if (!token) return false;
|
|
177
184
|
onBot?.('discord:configured');
|
|
178
185
|
const channelId = origin.discordThreadId || origin.channelId;
|
|
179
186
|
const out = spillLongContent(text).text;
|
|
@@ -184,6 +191,7 @@ function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: stri
|
|
|
184
191
|
`https://discord.com/api/v10/channels/${channelId}/messages`,
|
|
185
192
|
JSON.stringify({ content: out.slice(start, end) }),
|
|
186
193
|
[`Authorization: Bot ${token}`, 'Content-Type: application/json'],
|
|
194
|
+
'send a real Discord message',
|
|
187
195
|
);
|
|
188
196
|
if (typeof response?.id !== 'string' || !response.id) return false;
|
|
189
197
|
start = end;
|
|
@@ -195,6 +203,7 @@ function deliverToOrigin(origin: MissionOrigin, text: string, onBot?: (bot: stri
|
|
|
195
203
|
const token = resolveBotToken(origin.botId);
|
|
196
204
|
if (!token) return false;
|
|
197
205
|
onBot?.(botLabel(token));
|
|
206
|
+
assertTestSendFaked('send a real Telegram message to the origin');
|
|
198
207
|
try { return sendTelegramRaw(token, origin.chatId, text, origin.threadId); } catch { return false; }
|
|
199
208
|
}
|
|
200
209
|
|
|
@@ -287,6 +296,7 @@ export type FlushDeferredDeps = {
|
|
|
287
296
|
* ⭐ 실패한 묶음의 항목만 큐에 남긴다(성공 묶음은 다시 보내지 않는다 · 10-07 야간 11회 재발송 사고).
|
|
288
297
|
* 실패가 `maxAttempts` 번 쌓인 항목은 `<name>.failed.jsonl` 로 격리한다. */
|
|
289
298
|
export function flushDeferred(path = DEFERRED_PATH, deps: FlushDeferredDeps = {}): number {
|
|
299
|
+
assertNotTestWritingOps(path, 'flush the deferred-alert queue');
|
|
290
300
|
ensureOutboundLogSink();
|
|
291
301
|
const observeFlush = (
|
|
292
302
|
count: number, lagMin: number, kinds: string[],
|
|
@@ -452,8 +462,18 @@ export function flushDeferred(path = DEFERRED_PATH, deps: FlushDeferredDeps = {}
|
|
|
452
462
|
return delivered;
|
|
453
463
|
}
|
|
454
464
|
|
|
455
|
-
/**
|
|
456
|
-
|
|
465
|
+
/** TEST-PROD-LEAK — a test process may not reach a real bot or the ops daemon. `external` = a third-party bot API
|
|
466
|
+
* (refused unless curl is a test double replacing the real one); the local daemon is refused only when ops-routed. */
|
|
467
|
+
function assertTestSendFaked(what: string, external = true): void {
|
|
468
|
+
let roots: string[] = [];
|
|
469
|
+
try { roots = [getElanousConfigDir(), dirname(ACP_TOKEN_PATH), effectiveInstanceRoot(), conatusDataDir(), dirname(DEFERRED_PATH)]; }
|
|
470
|
+
catch { roots = [dirname(ACP_TOKEN_PATH), dirname(DEFERRED_PATH)]; }
|
|
471
|
+
assertNoRealSendFromTest(what, { transportFaked: isTestDouble(childProcess.execFileSync, REAL_EXEC_FILE_SYNC), routingRoots: roots, external });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** 동기 curl(POST). body 는 stdin. 파싱 실패/에러 → null. `what` names the send for the test guard (never the URL — it carries the token). */
|
|
475
|
+
function curlPost(url: string, body: string, headers: string[], what = 'send a real outbound message', external = true): any {
|
|
476
|
+
assertTestSendFaked(what, external);
|
|
457
477
|
const args = ['-s', '-m', '25', '-X', 'POST'];
|
|
458
478
|
for (const h of headers) args.push('-H', h);
|
|
459
479
|
args.push('--data', '@-', url);
|
|
@@ -560,6 +580,7 @@ export function setInProcessOutbound(send: InProcessOutbound | null): void { inP
|
|
|
560
580
|
export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => void, source?: string): 'daemon' | 'direct' | false {
|
|
561
581
|
// 0) inside the daemon — route in-process, asynchronously; the caller's synchronous answer is «accepted».
|
|
562
582
|
if (inProcessOutbound && process.env.SEND_VIA_ELANOUS !== '0') {
|
|
583
|
+
assertTestSendFaked('route through the in-process daemon sender', false);
|
|
563
584
|
onBot?.('daemon:configured');
|
|
564
585
|
const send = inProcessOutbound;
|
|
565
586
|
const fallback = (): void => {
|
|
@@ -592,7 +613,9 @@ export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => v
|
|
|
592
613
|
...(token ? [`Authorization: Bearer ${token}`] : [])];
|
|
593
614
|
const nexus = nexusUrl();
|
|
594
615
|
const j = nexus
|
|
595
|
-
|
|
616
|
+
// An explicit ELANOUS_NEXUS_URL can name any daemon, the ops one included: a test treats it like a real bot API.
|
|
617
|
+
? curlPost(`${nexus}/v1/outbound`, JSON.stringify({ text, markdown: false, kind }), headers, 'post to the daemon /v1/outbound',
|
|
618
|
+
Boolean(process.env.ELANOUS_NEXUS_URL?.trim()))
|
|
596
619
|
: null;
|
|
597
620
|
const classification = classifyDaemonResponse(j);
|
|
598
621
|
if (classification === 'ok') { onBot?.('daemon:configured'); return 'daemon'; }
|
|
@@ -602,27 +625,33 @@ export function deliver(text: string, kind = 'alert', onBot?: (bot: string) => v
|
|
|
602
625
|
logDaemonPath(classification, kind, extra);
|
|
603
626
|
}
|
|
604
627
|
// 2) fallback: 텔레그램 sendMessage 직접(3900자 분할) — 데몬 미경유라 클라가 원장 기록.
|
|
605
|
-
|
|
606
|
-
|
|
628
|
+
// The same config judges the route and explains a failure (review r1).
|
|
629
|
+
let cfg: UserConfig | undefined;
|
|
630
|
+
try { cfg = getUserConfig(); } catch { /* sendTelegramDirect fails closed without config */ }
|
|
631
|
+
if (sendTelegramDirect(text, kind, cfg ? { config: cfg } : {}, onBot)) return 'direct';
|
|
632
|
+
reportUndeliverable(kind, daemonPath, cfg);
|
|
607
633
|
return false;
|
|
608
634
|
}
|
|
609
635
|
|
|
610
636
|
/** OB8b — both paths failed: say so loudly, with the universe this process resolved, instead of a silent false.
|
|
611
637
|
* The usual cause (10-01 · MK): an ad-hoc `bun -e` or a script from a source tree resolves a cwd-derived test
|
|
612
638
|
* universe, so it finds neither the production daemon nor the production bot token. */
|
|
613
|
-
function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-found' | 'disabled'): void {
|
|
639
|
+
function reportUndeliverable(kind: string, daemonPath: DaemonPathClass | 'not-found' | 'disabled', cfg?: UserConfig): void {
|
|
614
640
|
let root = '?';
|
|
615
641
|
let universe: 'prod' | 'test' | '?' = '?';
|
|
616
642
|
try {
|
|
617
643
|
root = effectiveInstanceRoot();
|
|
618
644
|
universe = root === prodInstanceRoot() ? 'prod' : 'test';
|
|
619
645
|
} catch { /* the report still goes out */ }
|
|
646
|
+
// Which routing rule closed the direct path — «토큰 없음 또는 전송 실패» hid a missing role for four days (BRIEF-DELIVERY-1007).
|
|
647
|
+
let route: ReturnType<typeof explainReportRoute> | null = null;
|
|
648
|
+
try { if (cfg) route = explainReportRoute(cfg, kind); } catch { /* the report still goes out */ }
|
|
620
649
|
ensureOutboundLogSink();
|
|
621
|
-
try { debug.log('outbound.send', 'undeliverable', { kind: safeObservationLabel(kind), daemonPath, universe, root }); } catch { /* fail-soft */ }
|
|
650
|
+
try { debug.log('outbound.send', 'undeliverable', { kind: safeObservationLabel(kind), daemonPath, universe, root, route: route?.reason ?? 'unknown' }); } catch { /* fail-soft */ }
|
|
622
651
|
const hint = universe === 'prod'
|
|
623
652
|
? '운영 데몬이 떠 있는지 확인: elanous nexus show'
|
|
624
653
|
: '운영으로 보내려면 설치본 elanous 로 실행하거나 ELANOUS_STATE_DIR=~/.elanous 와 --config-dir ~/.elanous 를 준다';
|
|
625
|
-
try { console.error(`[outbound] ⛔ 못 보냄(${kind}) — 데몬 ${daemonPath} · 직접 발송도 실패(토큰 없음 또는 전송 실패) · 이 프로세스의 우주 ${universe} (${root}) · ${hint}`); } catch { /* fail-soft */ }
|
|
654
|
+
try { console.error(`[outbound] ⛔ 못 보냄(${kind}) — 데몬 ${daemonPath} · 직접 발송도 실패(${route && route.reason !== 'routed' ? `${route.reason}: ${route.hint}` : '토큰 없음 또는 전송 실패'}) · 이 프로세스의 우주 ${universe} (${root}) · ${hint}`); } catch { /* fail-soft */ }
|
|
626
655
|
}
|
|
627
656
|
|
|
628
657
|
/** 텔레그램 raw 발송(토큰·chatId 명시) — spill + 3900자 분할(줄 경계). thread 지원. */
|
|
@@ -641,7 +670,7 @@ function sendTelegramRaw(token: string, chatId: string | number, text: string, t
|
|
|
641
670
|
for (const ch of chunks) {
|
|
642
671
|
const params: Record<string, string> = { chat_id: String(chatId), text: ch, disable_web_page_preview: 'true' };
|
|
643
672
|
if (threadId !== undefined) params.message_thread_id = String(threadId);
|
|
644
|
-
if (!curlPost(url, new URLSearchParams(params).toString(), ['Content-Type: application/x-www-form-urlencoded'])) ok = false;
|
|
673
|
+
if (!curlPost(url, new URLSearchParams(params).toString(), ['Content-Type: application/x-www-form-urlencoded'], 'send a real Telegram message')) ok = false;
|
|
645
674
|
}
|
|
646
675
|
return ok;
|
|
647
676
|
}
|
|
@@ -653,6 +682,8 @@ export function sendTelegramDirect(
|
|
|
653
682
|
onBot?: (bot: string) => void,
|
|
654
683
|
): boolean {
|
|
655
684
|
const sendRaw = deps.sendRaw ?? sendTelegramRaw;
|
|
685
|
+
// An injected sender still may not run in a test that resolved the ops universe.
|
|
686
|
+
if (deps.sendRaw) assertTestSendFaked('send a Telegram message from the ops universe', false);
|
|
656
687
|
let cfg: UserConfig | undefined;
|
|
657
688
|
try { cfg = deps.config ?? getUserConfig(); } catch { /* unavailable config: operational delivery still fails closed */ }
|
|
658
689
|
if (cfg) {
|
|
@@ -2,7 +2,12 @@ import { inQuietHours, kstMinutes, sendOutbound, setInProcessOutbound } from './
|
|
|
2
2
|
import { setUserConfigOverlay } from '../user-config.js';
|
|
3
3
|
|
|
4
4
|
// Focused subprocess fixture: isolated state/config roots and a fake curl on PATH.
|
|
5
|
-
|
|
5
|
+
// Hermetic: the shared `.elanous-test` config may carry a routing table another test left behind (a declared
|
|
6
|
+
// `channels` table is authoritative and never falls back to the main home), so the fixture owns every routing key.
|
|
7
|
+
setUserConfigOverlay(cfg => ({ ...cfg, telegram: {
|
|
8
|
+
...cfg.telegram, channels: undefined, reportChannel: undefined, testChannel: undefined,
|
|
9
|
+
botToken: '123456:private-secret', homeChannel: 98765,
|
|
10
|
+
} }));
|
|
6
11
|
process.env.SEND_VIA_ELANOUS = '0';
|
|
7
12
|
// Quiet hours (00:00~06:30 KST) defer instead of sending, so a gate run at night saw no `sent` row.
|
|
8
13
|
// Shift this fixture's clock back to 23:59 KST of the previous day; the shift stays inside this process.
|
|
@@ -18,6 +18,9 @@ import { dirname, join } from 'node:path';
|
|
|
18
18
|
import { elanousStateRoot } from '../autopilot/state-paths.js';
|
|
19
19
|
import { prevScheduledFire } from './cron-match.js';
|
|
20
20
|
import { debug } from '../debug/log.js';
|
|
21
|
+
import { listSources } from '../intake-plane/intake-sources.js';
|
|
22
|
+
import { getUserConfig } from '../user-config.js';
|
|
23
|
+
import { effectiveInstanceRoot } from '../instance/resolve.js';
|
|
21
24
|
import { deriveScheduleName, ensureScheduleRunsSchema, recordScheduleRun } from './schedule-runs.js';
|
|
22
25
|
|
|
23
26
|
/** 스케줄 레지스트리 DB 정본 경로 — state-dir 존중(lazy · Phase B). prod(ELANOUS_STATE_DIR
|
|
@@ -490,6 +493,9 @@ export interface InventoryInternalOpts {
|
|
|
490
493
|
discoveryIntervalMs?: number;
|
|
491
494
|
/** 테스트 seam — workflow-runtime schedule 트리거 주입(미지정 시 discoverWorkflows). */
|
|
492
495
|
workflowSchedules?: Array<{ workflowName: string; nodeId: string; cron?: string | null; intervalMs?: number | null }>;
|
|
496
|
+
/** Test seams for the installed instance; production reads the active config and instance root. */
|
|
497
|
+
watchRoot?: string;
|
|
498
|
+
watchEnabled?: boolean;
|
|
493
499
|
}
|
|
494
500
|
|
|
495
501
|
/** 데몬 내부 스케줄을 레지스트리에 upsert(멱등·고정 id). 등록 수 반환. */
|
|
@@ -513,6 +519,23 @@ export function inventoryInternalSchedules(db: Database, opts: InventoryInternal
|
|
|
513
519
|
// 회고(retro loop = retro-aggregate/report/rebalance)와는 별개.
|
|
514
520
|
const hour = opts.reflectionHour ?? (Number.isFinite(Number.parseInt(process.env.ELANOUS_REFLECTION_HOUR ?? '', 10)) ? Number.parseInt(process.env.ELANOUS_REFLECTION_HOUR!, 10) : 21);
|
|
515
521
|
upsert('internal:daily-reflection', 'daily-reflection', 'daily-reflection', `0 ${hour} * * *`, null, 'daemon: elanous 활동 회고(노트/OCR/세션) 스냅샷 푸시', 'report', 'elanous');
|
|
522
|
+
// WATCH-DEFAULT — fail-soft: a broken sources file or config must not stop the other internal rows.
|
|
523
|
+
let watchSources: ReturnType<typeof listSources> = [];
|
|
524
|
+
try {
|
|
525
|
+
const watchEnabled = opts.watchEnabled ?? (getUserConfig().watch?.enabled !== false);
|
|
526
|
+
watchSources = watchEnabled ? listSources({ seat: 'user' }, opts.watchRoot ?? effectiveInstanceRoot()) : [];
|
|
527
|
+
} catch (error) {
|
|
528
|
+
debug.log('watch.default', 'failed', { stage: 'inventory', reason: error instanceof Error ? error.message : String(error) });
|
|
529
|
+
}
|
|
530
|
+
if (watchSources.length > 0) {
|
|
531
|
+
upsert('internal:watch-collect', 'watch-collect', 'watch-default', '0 7 * * *', null,
|
|
532
|
+
'intake source run-due --seat user', 'ingest', 'elanous');
|
|
533
|
+
upsert('internal:watch-brief', 'watch-brief', 'watch-default', '30 8 * * *', null,
|
|
534
|
+
'intake digest --seat user --telegram', 'digest', 'elanous');
|
|
535
|
+
debug.log('watch.default', 'scheduled', { topics: watchSources.length, items: 0, sent: false });
|
|
536
|
+
} else {
|
|
537
|
+
db.run("DELETE FROM schedule_registry WHERE id IN ('internal:watch-collect', 'internal:watch-brief') AND source = 'watch-default'");
|
|
538
|
+
}
|
|
516
539
|
// discovery (env 설정 시에만 가동) — 코어 인프라 → elanous.
|
|
517
540
|
const dm = opts.discoveryIntervalMs ?? Number.parseInt(process.env.ELANOUS_DISCOVERY_CRON_INTERVAL_MS ?? '', 10);
|
|
518
541
|
if (Number.isFinite(dm) && dm > 0) {
|
|
@@ -75,3 +75,42 @@ export function kindRouteTarget(cfg: UserConfig, kind?: string): { botToken: str
|
|
|
75
75
|
if (!ch || !Number.isFinite(ch.chatId)) return null;
|
|
76
76
|
return { botToken: ch.botToken, chatId: ch.chatId };
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
export type ReportRouteReason =
|
|
80
|
+
| 'routed' | 'report-role-missing' | 'no-role-channel'
|
|
81
|
+
| 'no-report-channel' | 'report-bot-not-distinct' | 'report-chat-invalid' | 'no-main-home';
|
|
82
|
+
|
|
83
|
+
/** Why a kind has (or lacks) a delivery target — no logging, no sending. Mirrors
|
|
84
|
+
* `resolveReportTarget` so a skipped send can say *which* rule closed it instead of
|
|
85
|
+
* «no report channel configured» (BRIEF-DELIVERY-1007: a channels table without a
|
|
86
|
+
* `report` role closed the morning brief for four days while reportChannel was set). */
|
|
87
|
+
export function explainReportRoute(cfg: UserConfig, kind?: string): { reason: ReportRouteReason; role: string; hint: string } {
|
|
88
|
+
const role = roleForKind(cfg, kind);
|
|
89
|
+
const trading = isTradingKind(kind);
|
|
90
|
+
if (cfg.telegram.channels?.length) {
|
|
91
|
+
if (kindRouteTarget(cfg, kind)) return { reason: 'routed', role, hint: '' };
|
|
92
|
+
const hasRole = resolveTelegramChannels(cfg.telegram).some(c => c.roles.includes(role));
|
|
93
|
+
if (trading && !hasRole && cfg.telegram.reportChannel) {
|
|
94
|
+
return {
|
|
95
|
+
reason: 'report-role-missing', role,
|
|
96
|
+
hint: `채널 표에 «${role}» 역할 칸이 없다 — telegram.reportChannel 은 채널 표가 있으면 쓰지 않는다. `
|
|
97
|
+
+ `telegram.channels[].roles 에 "${role}" 를 더하거나 telegram.kindRoles.${kind ?? '<kind>'} 로 있는 역할을 고른다`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return { reason: 'no-role-channel', role, hint: `채널 표에 «${role}» 역할을 가진 허용된 봇 칸이 없다 — telegram.channels 를 확인` };
|
|
101
|
+
}
|
|
102
|
+
if (!trading) {
|
|
103
|
+
return mainHomeTarget(cfg)
|
|
104
|
+
? { reason: 'routed', role, hint: '' }
|
|
105
|
+
: { reason: 'no-main-home', role, hint: '운영 봇 또는 telegram.homeChannel·allowedUsers 가 없다' };
|
|
106
|
+
}
|
|
107
|
+
const rc = cfg.telegram.reportChannel;
|
|
108
|
+
if (!rc) return { reason: 'no-report-channel', role, hint: 'telegram.reportChannel 이 없다' };
|
|
109
|
+
const mainToken = resolveChannelBotToken('telegram', cfg).token;
|
|
110
|
+
if (!rc.botToken || rc.botToken === mainToken) {
|
|
111
|
+
return { reason: 'report-bot-not-distinct', role, hint: 'telegram.reportChannel 의 봇이 없거나 운영 봇과 같다 — 매매 보고는 별도 봇만 쓴다' };
|
|
112
|
+
}
|
|
113
|
+
return Number.isFinite(rc.chatId)
|
|
114
|
+
? { reason: 'routed', role, hint: '' }
|
|
115
|
+
: { reason: 'report-chat-invalid', role, hint: 'telegram.reportChannel.chatId 가 숫자가 아니다' };
|
|
116
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { debug } from '../debug/log.js';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { getUserConfig } from '../user-config.js';
|
|
5
|
+
import { effectiveInstanceRoot } from '../instance/resolve.js';
|
|
6
|
+
import { listSources, runDue } from '../intake-plane/intake-sources.js';
|
|
7
|
+
import { runIntakeDigestCli } from '../cli/intake-cli.js';
|
|
8
|
+
import { resolveTimeZone } from '../time/format.js';
|
|
9
|
+
import { inventoryInternalSchedules, openSchedulesDb } from './schedule-registry.js';
|
|
10
|
+
import type { Database } from 'bun:sqlite';
|
|
11
|
+
|
|
12
|
+
export interface WatchDefaultSchedulerOpts {
|
|
13
|
+
root?: string;
|
|
14
|
+
enabled?: () => boolean;
|
|
15
|
+
now?: () => Date;
|
|
16
|
+
timeZone?: string;
|
|
17
|
+
collect?: () => Promise<unknown>;
|
|
18
|
+
brief?: () => Promise<unknown>;
|
|
19
|
+
setInterval?: (tick: () => void, ms: number) => ReturnType<typeof setInterval>;
|
|
20
|
+
openDb?: () => Database;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Daemon-owned watch jobs: the inventory rows describe these two actual timers. */
|
|
24
|
+
export function startWatchDefaultScheduler(opts: WatchDefaultSchedulerOpts = {}): { stop(): void; tickNow(): Promise<void> } {
|
|
25
|
+
const root = opts.root ?? effectiveInstanceRoot();
|
|
26
|
+
const enabled = opts.enabled ?? (() => getUserConfig().watch?.enabled !== false);
|
|
27
|
+
const timeZone = opts.timeZone ?? resolveTimeZone().timeZone;
|
|
28
|
+
const now = opts.now ?? (() => new Date());
|
|
29
|
+
const collect = opts.collect ?? (() => runDue({ seat: 'user', deps: { stateDir: root } }));
|
|
30
|
+
// The CLI sets process.exitCode on an unsent report; inside the daemon that would leak into the daemon's own exit status.
|
|
31
|
+
const brief = opts.brief ?? (async () => {
|
|
32
|
+
const before = process.exitCode;
|
|
33
|
+
try { return await runIntakeDigestCli({ seat: 'user', telegram: true }, { root }); }
|
|
34
|
+
finally { process.exitCode = before; }
|
|
35
|
+
});
|
|
36
|
+
const lastRunFile = join(root, 'intake', 'watch-default-last-run.json');
|
|
37
|
+
const lastRun = new Map<string, string>();
|
|
38
|
+
let lastInventory: string | undefined;
|
|
39
|
+
let lastFailure: string | undefined;
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(lastRunFile)) {
|
|
42
|
+
const saved = JSON.parse(readFileSync(lastRunFile, 'utf8')) as Record<string, string>;
|
|
43
|
+
for (const [job, day] of Object.entries(saved)) if (typeof day === 'string') lastRun.set(job, day);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) { debug.log('watch.default', 'failed', { reason: `last-run: ${String(error)}` }); }
|
|
46
|
+
let running = false;
|
|
47
|
+
let stopped = false;
|
|
48
|
+
const tickNow = async (): Promise<void> => {
|
|
49
|
+
if (stopped || running) return;
|
|
50
|
+
running = true;
|
|
51
|
+
try {
|
|
52
|
+
const active = enabled() && listSources({ seat: 'user' }, root).length > 0;
|
|
53
|
+
const registryDay = now().toISOString().slice(0, 10);
|
|
54
|
+
const inventoryKey = `${registryDay}:${active}`;
|
|
55
|
+
if (lastInventory !== inventoryKey) {
|
|
56
|
+
// The registry row is a view; a registry failure must not skip the real collect/brief of this minute.
|
|
57
|
+
try {
|
|
58
|
+
const db = (opts.openDb ?? openSchedulesDb)();
|
|
59
|
+
try { inventoryInternalSchedules(db, { watchRoot: root, watchEnabled: active }); lastInventory = inventoryKey; }
|
|
60
|
+
finally { if (!opts.openDb) db.close(); }
|
|
61
|
+
} catch (error) { debug.log('watch.default', 'failed', { stage: 'registry', reason: String(error) }); }
|
|
62
|
+
}
|
|
63
|
+
lastFailure = undefined;
|
|
64
|
+
if (!active) return;
|
|
65
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
66
|
+
timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
|
67
|
+
}).formatToParts(now());
|
|
68
|
+
const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? '';
|
|
69
|
+
const day = `${part('year')}-${part('month')}-${part('day')}`;
|
|
70
|
+
const clock = `${part('hour')}:${part('minute')}`;
|
|
71
|
+
for (const [job, at, fire] of [['watch-collect', '07:00', collect], ['watch-brief', '08:30', brief]] as const) {
|
|
72
|
+
if (clock !== at || lastRun.get(job) === day) continue;
|
|
73
|
+
try {
|
|
74
|
+
const outcome = await fire();
|
|
75
|
+
if (job === 'watch-brief' && (!outcome || (outcome as { sent?: boolean }).sent !== true)) continue;
|
|
76
|
+
if (job === 'watch-collect' && typeof outcome === 'object' && outcome && 'ran' in outcome
|
|
77
|
+
&& (outcome as { ran: Array<{ error?: string }> }).ran.some((row) => row.error)) continue;
|
|
78
|
+
lastRun.set(job, day);
|
|
79
|
+
mkdirSync(join(root, 'intake'), { recursive: true });
|
|
80
|
+
const temp = `${lastRunFile}.${process.pid}.tmp`;
|
|
81
|
+
writeFileSync(temp, JSON.stringify(Object.fromEntries(lastRun)) + '\n');
|
|
82
|
+
renameSync(temp, lastRunFile);
|
|
83
|
+
} catch (error) { debug.log('watch.default', 'failed', { job, reason: String(error) }); }
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const reason = String(error);
|
|
87
|
+
if (reason !== lastFailure) debug.log('watch.default', 'failed', { reason });
|
|
88
|
+
lastFailure = reason;
|
|
89
|
+
} finally { running = false; }
|
|
90
|
+
};
|
|
91
|
+
const timer = (opts.setInterval ?? setInterval)(() => { void tickNow(); }, 60_000);
|
|
92
|
+
if (typeof timer === 'object') timer.unref?.();
|
|
93
|
+
void tickNow();
|
|
94
|
+
return { stop() { stopped = true; clearInterval(timer); }, tickNow };
|
|
95
|
+
}
|