elanous 0.2.14 → 0.2.15
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/LZcstaO9_NXypPFmujolv/_buildManifest.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{1307-6e7e0377c1db0bdc.js → 1307-33ffcc49ab45123b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/5977-c952712c87a1f757.js +1 -0
- package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/{page-74383f890f075511.js → page-0e2c57fe6add4b2a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-7b2b4f378354869a.js → page-e05a77df7e005b04.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-f544338193a9a762.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-5491c115b1f9a923.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-1512d91c7491ed62.js → page-b8c49f6b4fa2df91.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-96398e364fbd4548.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-a627db94ec6904a0.js → page-976813c91d7394ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-fb260b9283dbf646.js → page-28b9448091776c06.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-1d4901eb7c2d8822.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-72d3e7ff35a8c954.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-fed69f9759d248d0.js → page-60c7a8e1a4fe62ce.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-15374909d8f1aebd.js → page-2a52b316cb143d9c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-012a742f81218b01.js → page-c3ce483c8d5db370.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-13c7b53e0c2519a9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-99effd9d893a43dc.js → page-96f074f6538e8a2c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{layout-938de57ae0315e46.js → layout-e97be5334d83a9f5.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-65e484302921a932.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/loops/page-34d08facaf08f556.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/{page-629e1ede482ec6eb.js → page-955ec2da3be4c560.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-9f88fbc15d9058d1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-27e99b4e54a849d4.js → page-c1343f9499d86f3f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-8739a2e0ae793130.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-c9fc5706b8abaa11.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-32cde713be47cddb.js → page-d23b12bb92242558.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-8d47f45a0dc044ac.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-c7fea475488201a8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/{page-04250f0e2c0d03df.js → page-e2ee2896811e98d0.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/page-83b19dae8619de05.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-e64caff084f7ce21.js → page-5efbf81124c474ae.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-db07eaffba0df7e3.js → page-85f1a1fc0a82738c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-8ac8d71c2adf9c8a.js → page-da6c08aae0f92768.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-06ee266953d9c879.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-86f91034bd5cabe5.js → page-153c2b3bcc89e8de.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-94dc383d3d54db1a.js → page-87a11e6a05f15199.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-b3759cc14da80908.js → page-7d4182964407c09c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/{page-ce4d8a1215ce53a3.js → page-0d6d23b40263a7ea.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-7e8c39241f8eb6d0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-008c1a96618a4887.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/{page-274703bc41b71333.js → page-f5597e894c02cd33.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-0337fdf7fb5e37b6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-35bdeae2e2c17147.js → page-73735b64bfe1ae67.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/{page-3b7e821dc492bdfd.js → page-8fcec4c9ea2e7f70.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-f7e631e9b4daf66e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-4881b8f9f63694b4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-2f810b18d89ecba6.js → page-9817c7f9007466cc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-134c1bc762aee62c.js → page-97f9f43172a3a90e.js} +1 -1
- package/apps/pwa/out/_next/static/css/1f7c958c41ffa3b9.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 -0
- package/apps/pwa/out/ceo/index.txt +33 -0
- 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/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 +14 -14
- 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 +14 -14
- 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 +14 -14
- 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/heal/heal-loop.yaml +1 -1
- package/graphs/implement-loop.yaml +1 -1
- package/graphs/release/recipes.yaml +3 -0
- package/graphs/release/release-loop.yaml +4 -2
- package/package.json +1 -1
- package/scripts/brand/check.ts +7 -5
- package/scripts/ci-typecheck-changed.ts +50 -18
- package/scripts/docs/generate-graph-anatomy.ts +315 -0
- package/scripts/expose-rubric-suggest.ts +18 -12
- package/scripts/lecture-note/node.ts +31 -11
- package/scripts/lessons/handbook-page.ts +30 -3
- package/scripts/marketing/generate-ax-brief.ts +140 -0
- package/scripts/marketing/generate-feature-map.ts +118 -0
- package/scripts/release-loop/checklist-gate-node.ts +53 -14
- package/scripts/release-loop/gate-node.ts +80 -14
- package/scripts/release-loop/vault-note-node.ts +240 -8
- package/scripts/release-story/draft.ts +57 -9
- package/scripts/rhythm/daily.ts +263 -76
- package/scripts/rhythm/weekly.ts +30 -9
- package/scripts/smart-merge/resync-shadow.ts +147 -0
- package/src/agent-env/profile.ts +161 -0
- package/src/agent-substrate/pr-reviewer.ts +2 -0
- package/src/ask-user-question/pending-questions.ts +5 -0
- package/src/auth/github-app-token.ts +112 -13
- package/src/autopilot/build/llm-conflict-merge.ts +32 -6
- package/src/briefing/brief-items.ts +121 -0
- package/src/claims/claims-render.ts +14 -5
- package/src/cli/agent-env-cli.ts +21 -0
- package/src/cli/decisions-cli.ts +9 -0
- package/src/cli/doctor-cli.ts +23 -1
- package/src/cli/doctor-node-modules.ts +147 -0
- package/src/cli/intake-cli.ts +46 -17
- package/src/cli/pod-cli.ts +2 -0
- package/src/cli/release-cli.ts +61 -27
- package/src/cli/seat-requests-cli.ts +52 -0
- package/src/context-bus/context-now-public.ts +16 -6
- package/src/context-bus/context-now.ts +3 -1
- package/src/context-bus/coord-events.ts +6 -4
- package/src/decisions/decision-cards.ts +33 -7
- package/src/decisions/proact-meter.ts +286 -0
- package/src/decisions/proact1-lite.ts +86 -0
- package/src/decisions/web-push-decision-cards.ts +1 -1
- package/src/domains/alert-audit.ts +271 -0
- package/src/domains/core-tools.ts +4 -3
- package/src/domains/ops-facts-tool.ts +31 -1
- package/src/harness/harness-cli-command.ts +111 -15
- package/src/harness/harness-incidents.ts +2 -0
- package/src/harness/harness-pod-dispatch.ts +100 -11
- package/src/harness/harness-queue.ts +380 -50
- package/src/harness/helper-repair.ts +87 -0
- package/src/harness/helper-scan.ts +16 -4
- package/src/harness/launch-stamp.ts +236 -0
- package/src/harness/ledger-write-guard-child.fixture.ts +12 -0
- package/src/harness/ledger-write-guard.ts +35 -0
- package/src/harness/pod-memory-policy.ts +15 -0
- package/src/harness/repository-name.ts +55 -0
- package/src/hq/fence-audit.ts +90 -0
- package/src/hq/hq.ts +62 -4
- package/src/hq/lease.ts +4 -4
- package/src/hq/move-plan.ts +126 -0
- package/src/hq/promote.ts +31 -0
- package/src/index.ts +77 -7
- package/src/intake-plane/check.ts +63 -0
- package/src/intake-plane/digest.ts +37 -15
- package/src/intake-plane/front-classifier.ts +6 -0
- package/src/intake-plane/items.ts +3 -2
- package/src/intake-plane/lens.ts +177 -0
- package/src/intake-plane/route.ts +145 -1
- package/src/launch-head/seat-goal-queue-lock.fixture.ts +15 -0
- package/src/launch-head/seat-goal-queue.ts +198 -0
- package/src/loops/budget.ts +36 -5
- package/src/loops/checker.ts +2 -2
- package/src/loops/orchestrator/author-depth.ts +65 -9
- package/src/loops/orchestrator/author-input-check.ts +8 -3
- package/src/loops/orchestrator/author-ledger.ts +3 -1
- package/src/loops/orchestrator/seat-share.ts +56 -0
- package/src/loops/orchestrator/tick.ts +11 -4
- package/src/loops/orchestrator/traffic.ts +117 -11
- package/src/loops/registry.ts +142 -61
- package/src/maturity/feature-maturity.ts +2 -1
- package/src/maturity/tui-slash-availability.ts +31 -0
- package/src/mss/logging/log-store.ts +35 -4
- package/src/nexus/api/http-server.ts +28 -14
- package/src/nexus/api/loop-edges.ts +173 -0
- package/src/nexus/api/schedules-read.ts +13 -0
- package/src/nexus/api/seat-requests.ts +79 -56
- package/src/prompt-enhance/enhance.ts +6 -1
- package/src/release-loop/auto-start.ts +6 -3
- package/src/release-loop/checklist.ts +13 -4
- package/src/release-loop/feature-store.ts +58 -31
- package/src/release-loop/merged-pr-checklist.ts +125 -0
- package/src/release-loop/placement.ts +86 -26
- package/src/seat-dispatch/seat-request-eligibility.ts +19 -0
- package/src/seat-dispatch/seat-request-ledger.ts +133 -0
- package/src/seat-loop/seat-loop.ts +126 -10
- package/src/self-dev/dev-pipeline.ts +8 -4
- package/src/self-dev/draft-sweep.ts +90 -10
- package/src/self-dev/draft-triage-rules.ts +25 -8
- package/src/self-dev/orchestrate.ts +15 -1
- package/src/self-dev/review-provider-fallback.ts +6 -1
- package/src/self-dev/run-supervisor.ts +49 -2
- package/src/self-implement/frozen-merges.ts +3 -2
- package/src/self-implement/goal-author.ts +43 -5
- package/src/self-implement/goal-clarification-escalation.ts +5 -0
- package/src/self-implement/heal-intake.ts +120 -2
- package/src/self-implement/host-regate.ts +9 -2
- package/src/self-implement/l8-merge-queue.ts +132 -0
- package/src/self-implement/orchestrator.ts +187 -40
- package/src/self-implement/pipeline-shape.ts +1 -1
- package/src/self-implement/revert-guard.ts +42 -7
- package/src/self-implement/run-ledger.ts +2 -0
- package/src/self-implement/run-status-mapping.ts +2 -0
- package/src/self-implement/seams.ts +1 -1
- package/src/self-implement/self-implement-cli.ts +1 -0
- package/src/self-implement/speed-claims.ts +338 -0
- package/src/steward/triage.ts +74 -3
- package/src/steward/working-backwards.ts +4 -1
- package/src/task-orchestrator/surfaces/pod-artifact-return.ts +63 -48
- package/src/task-orchestrator/surfaces/pod-command-job.ts +84 -9
- package/src/task-orchestrator/surfaces/pod-failure-reason.ts +48 -0
- package/src/task-orchestrator/surfaces/pod-pool.ts +60 -8
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +147 -36
- package/src/task-orchestrator/surfaces/self-implement.ts +5 -1
- package/src/telegram.ts +25 -4
- package/src/user-config.ts +29 -5
- package/src/version/packed-revision.json +1 -1
- package/src/workflow-runtime/nodes/bash.ts +9 -1
- package/apps/pwa/out/_next/static/chunks/4562-071d8bac44a01069.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5977-2df489622a52a33a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-c496bb16da85385f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-1abfe3aec3be6bdc.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/control/page-e6b84ab07d3567d5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/page-61ec1d2e92bdd50a.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-fd7e200d4d83fbd7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-d47266766766d227.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-160089123a40dd91.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-2ce5471926761fdd.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-e995d30f6800b6bf.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-39f89c53038a51d4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-e7e10cd3386222e5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-5a753bb4ec0cc511.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-341480ed8e3c1ea8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-e1e47d8d940aba3d.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-58043db78ba519ae.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-357750dfb91bf960.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-194dce4a83143fce.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-16bef3da05a900c3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-f8da5dc6f3752276.js +0 -1
- package/apps/pwa/out/_next/static/css/2e33713c444d5b39.css +0 -1
- package/apps/pwa/out/_next/static/x6mjI6vqOkVVu0jFuDkz1/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{x6mjI6vqOkVVu0jFuDkz1 → LZcstaO9_NXypPFmujolv}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// 흡수 렌즈 — outbox 항목마다 «우리에게 무엇인가»를 판정해 outbox/lens/<day>.jsonl 에만 남긴다.
|
|
2
|
+
// 원 갈래(goals·manual·review·release)는 읽기만 한다. LLM 은 judge 한 번(id 당).
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { debug } from '../debug/log.js';
|
|
6
|
+
import { judge } from '../llm/judge-layer.js';
|
|
7
|
+
import { intakeOutboxDir } from './route.js';
|
|
8
|
+
|
|
9
|
+
export const LENS_VERDICTS = ['대체 후보', '보강', '경쟁 대조', '참고'] as const;
|
|
10
|
+
export type LensVerdict = (typeof LENS_VERDICTS)[number];
|
|
11
|
+
|
|
12
|
+
export const LENS_BRANCHES = ['goals', 'manual', 'review', 'release'] as const;
|
|
13
|
+
export const LENS_DAILY_CAP = 20;
|
|
14
|
+
export const LENS_FACT_CAP = 12;
|
|
15
|
+
|
|
16
|
+
export interface LensRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
at: string;
|
|
19
|
+
lensVerdict: LensVerdict;
|
|
20
|
+
why: string;
|
|
21
|
+
target: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LensJudgeInput {
|
|
25
|
+
id: string;
|
|
26
|
+
facts: { fact: string; current: string }[];
|
|
27
|
+
url?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LensJudge = (input: LensJudgeInput) => Promise<unknown>;
|
|
31
|
+
|
|
32
|
+
export interface AnnotateIntakeLensDeps {
|
|
33
|
+
judge?: LensJudge;
|
|
34
|
+
now?: () => string;
|
|
35
|
+
log?: (event: string, data: Record<string, unknown>) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AnnotateIntakeLensResult {
|
|
39
|
+
judged: number;
|
|
40
|
+
skipped: number;
|
|
41
|
+
failed: number;
|
|
42
|
+
capped: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GroupedItem {
|
|
46
|
+
id: string;
|
|
47
|
+
facts: { fact: string; current: string }[];
|
|
48
|
+
url?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJsonl(file: string): Record<string, unknown>[] {
|
|
52
|
+
if (!existsSync(file)) return [];
|
|
53
|
+
return readFileSync(file, 'utf8').split('\n').filter(Boolean).flatMap((line) => {
|
|
54
|
+
try { const row = JSON.parse(line) as unknown; return row && typeof row === 'object' && !Array.isArray(row) ? [row as Record<string, unknown>] : []; }
|
|
55
|
+
catch { return []; }
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isTestProcess(): boolean {
|
|
60
|
+
return process.env.NODE_ENV === 'test' || Boolean(process.env.ELANOUS_TEST_HOME);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLensVerdict(value: unknown): value is LensVerdict {
|
|
64
|
+
return typeof value === 'string' && (LENS_VERDICTS as readonly string[]).includes(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 스키마 밖이면 null — 기록하지 않는다. why 는 한 문장이다. */
|
|
68
|
+
export function coerceLensVerdict(raw: unknown): Omit<LensRecord, 'id' | 'at'> | null {
|
|
69
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
70
|
+
const row = raw as Record<string, unknown>;
|
|
71
|
+
const why = typeof row.why === 'string' ? row.why.trim() : '';
|
|
72
|
+
const target = typeof row.target === 'string' ? row.target.trim() : '';
|
|
73
|
+
if (!isLensVerdict(row.lensVerdict) || !why || !target) return null;
|
|
74
|
+
if (/[\r\n]/.test(why)) return null;
|
|
75
|
+
return { lensVerdict: row.lensVerdict, why, target };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function groupOutbox(root: string, day: string): GroupedItem[] {
|
|
79
|
+
const out = intakeOutboxDir(root);
|
|
80
|
+
const order: string[] = [];
|
|
81
|
+
const groups = new Map<string, GroupedItem>();
|
|
82
|
+
for (const branch of LENS_BRANCHES) {
|
|
83
|
+
for (const row of readJsonl(join(out, branch, `${day}.jsonl`))) {
|
|
84
|
+
const id = typeof row.id === 'string' ? row.id.trim() : '';
|
|
85
|
+
const fact = typeof row.fact === 'string' ? row.fact.trim() : '';
|
|
86
|
+
const current = typeof row.current === 'string' ? row.current.trim() : '';
|
|
87
|
+
if (!id || !fact) continue;
|
|
88
|
+
let group = groups.get(id);
|
|
89
|
+
if (!group) {
|
|
90
|
+
group = { id, facts: [] };
|
|
91
|
+
groups.set(id, group);
|
|
92
|
+
order.push(id);
|
|
93
|
+
}
|
|
94
|
+
if (!group.url && typeof row.url === 'string' && row.url.trim()) group.url = row.url.trim();
|
|
95
|
+
if (group.facts.length < LENS_FACT_CAP) group.facts.push({ fact, current });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return order.map((id) => groups.get(id)!);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function judgedIds(file: string): Set<string> {
|
|
102
|
+
return new Set(readJsonl(file).flatMap((row) => typeof row.id === 'string' && row.id ? [row.id] : []));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function lensPrompt(item: GroupedItem): string {
|
|
106
|
+
const lines = item.facts.map((row, i) => `${i + 1}. fact: ${row.fact}\n current: ${row.current || '(없음)'}`);
|
|
107
|
+
return [
|
|
108
|
+
'SYSTEM:',
|
|
109
|
+
'흡수 항목이 우리에게 무엇인가를 한 번만 판정한다. 경로를 나열하지 말고, 왜 우리에게 그 판정인지를 한 문장으로 말한다.',
|
|
110
|
+
'lensVerdict 는 다음 넷 중 하나다: 대체 후보 · 보강 · 경쟁 대조 · 참고.',
|
|
111
|
+
'target 은 닿는 우리 쪽 이름 하나(기능·칸·파일)다.',
|
|
112
|
+
'JSON 만 답한다: {"lensVerdict":"...","why":"...","target":"..."}',
|
|
113
|
+
'',
|
|
114
|
+
`id: ${item.id}`,
|
|
115
|
+
...(item.url ? [`url: ${item.url}`] : []),
|
|
116
|
+
...lines,
|
|
117
|
+
].join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function defaultJudge(item: GroupedItem): Promise<unknown> {
|
|
121
|
+
const decision = await judge({
|
|
122
|
+
site: 'intake.lens',
|
|
123
|
+
prompt: lensPrompt(item),
|
|
124
|
+
schema: (value) => coerceLensVerdict(value),
|
|
125
|
+
});
|
|
126
|
+
if (!decision.ok) {
|
|
127
|
+
if (decision.reason === 'schema') return null;
|
|
128
|
+
throw decision.error instanceof Error ? decision.error : new Error(String(decision.error));
|
|
129
|
+
}
|
|
130
|
+
return decision.value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 그날 outbox 를 id 로 묶어 아직 없는 판정만 남긴다. 원 갈래 파일은 쓰지 않는다. */
|
|
134
|
+
export async function annotateIntakeLens(
|
|
135
|
+
root: string,
|
|
136
|
+
day: string,
|
|
137
|
+
deps: AnnotateIntakeLensDeps = {},
|
|
138
|
+
): Promise<AnnotateIntakeLensResult> {
|
|
139
|
+
const log = deps.log ?? ((event, data) => { debug.log('intake.lens', event, data); });
|
|
140
|
+
const empty = { judged: 0, skipped: 0, failed: 0, capped: false };
|
|
141
|
+
if (!deps.judge && isTestProcess()) {
|
|
142
|
+
log('skipped-test', {});
|
|
143
|
+
return empty;
|
|
144
|
+
}
|
|
145
|
+
const items = groupOutbox(root, day);
|
|
146
|
+
const file = join(intakeOutboxDir(root), 'lens', `${day}.jsonl`);
|
|
147
|
+
const done = judgedIds(file);
|
|
148
|
+
const pending = items.filter((item) => !done.has(item.id));
|
|
149
|
+
const capped = pending.length > LENS_DAILY_CAP;
|
|
150
|
+
const batch = pending.slice(0, LENS_DAILY_CAP);
|
|
151
|
+
if (capped) log('capped', { day, total: pending.length, judged: batch.length });
|
|
152
|
+
const judgeOne = deps.judge ?? ((input: LensJudgeInput) => defaultJudge(input));
|
|
153
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
154
|
+
let judged = 0;
|
|
155
|
+
let failed = 0;
|
|
156
|
+
for (const item of batch) {
|
|
157
|
+
let raw: unknown;
|
|
158
|
+
try {
|
|
159
|
+
raw = await judgeOne({ id: item.id, facts: item.facts, ...(item.url ? { url: item.url } : {}) });
|
|
160
|
+
} catch (error) {
|
|
161
|
+
failed++;
|
|
162
|
+
log('judge-failed', { id: item.id, reason: error instanceof Error ? error.message : String(error) });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const verdict = coerceLensVerdict(raw);
|
|
166
|
+
if (!verdict) {
|
|
167
|
+
failed++;
|
|
168
|
+
log('judge-failed', { id: item.id, reason: 'schema' });
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
mkdirSync(join(intakeOutboxDir(root), 'lens'), { recursive: true });
|
|
172
|
+
const record: LensRecord = { id: item.id, at: now(), ...verdict };
|
|
173
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`);
|
|
174
|
+
judged++;
|
|
175
|
+
}
|
|
176
|
+
return { judged, skipped: items.length - pending.length, failed, capped };
|
|
177
|
+
}
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// ⛔ 원장의 `text`(개인 메모일 수 있다)는 큐에 싣지 않는다 — 대조가 낸 사실·근거와 노트 경로만.
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { Database } from 'bun:sqlite';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
import { debug } from '../debug/log.js';
|
|
8
|
-
import {
|
|
9
|
+
import { getToxRuntimeDeps } from '../task-orchestrator/runtime-deps.js';
|
|
10
|
+
import type { TaskStore } from '../task-orchestrator/store.js';
|
|
11
|
+
import type { TaskStatus } from '../task-orchestrator/types.js';
|
|
12
|
+
import { canonicalUrl, ingestIntakeItems, intakeItemId, intakeLedgerDir, loadIntakeLedger, markIntakeItem, type IntakeItem } from './items.js';
|
|
9
13
|
|
|
10
14
|
/** `elanous intake check --json` 산출 중 갈래가 읽는 칸. */
|
|
11
15
|
export interface IntakeCheckJson {
|
|
@@ -29,6 +33,146 @@ export const gitLastCommitOf: LastCommitOf = (tree, path) => {
|
|
|
29
33
|
|
|
30
34
|
export function intakeOutboxDir(root: string): string { return join(intakeLedgerDir(root), 'outbox'); }
|
|
31
35
|
|
|
36
|
+
export const INTAKE_EVENT_DEFERRED_REPLY = '일일 즉시 흡수 상한을 넘어 다음 일일 판으로 미뤘습니다.';
|
|
37
|
+
export type IntakeEventResult = { status: 'scheduled' | 'deferred' | 'duplicate'; reason: string; url: string; reply?: string };
|
|
38
|
+
|
|
39
|
+
// A lost claim must become eligible at the next daily pass; live tasks remain excluded.
|
|
40
|
+
const INTAKE_EVENT_LEASE_MS = 23 * 3600_000;
|
|
41
|
+
|
|
42
|
+
// A non-terminal task that has not been touched for this long is treated as a dead worker.
|
|
43
|
+
const INTAKE_EVENT_TASK_STALE_MS = 2 * 3600_000;
|
|
44
|
+
|
|
45
|
+
/** A probe may return the bare status, or the status with the task's last update (epoch ms). */
|
|
46
|
+
export type IntakeEventTaskProbe = (id: string) => TaskStatus | { status: TaskStatus; updatedAt?: number } | undefined;
|
|
47
|
+
|
|
48
|
+
function eventTaskStatus(taskId: string): { status: TaskStatus; updatedAt?: number } | undefined {
|
|
49
|
+
const deps = getToxRuntimeDeps();
|
|
50
|
+
const graphTask = deps.getGraph()?.getTask(taskId) ?? (deps.getStore?.() as TaskStore | null | undefined)?.getTask(taskId);
|
|
51
|
+
return graphTask ? { status: graphTask.status, updatedAt: graphTask.updatedAt } : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function probeStatus(probe: IntakeEventTaskProbe, id: string): { status?: TaskStatus; updatedAt?: number } {
|
|
55
|
+
const value = probe(id);
|
|
56
|
+
return typeof value === 'string' ? { status: value } : value ?? {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type EventClaim = { status: string; task_id: string | null; claimed_at: string | null; day: string };
|
|
60
|
+
|
|
61
|
+
function activeEventClaim(row: EventClaim, now: string, taskStatus: IntakeEventTaskProbe): boolean {
|
|
62
|
+
if (row.status !== 'scheduled' && row.status !== 'pending') return false;
|
|
63
|
+
const { status, updatedAt } = row.task_id ? probeStatus(taskStatus, row.task_id) : {};
|
|
64
|
+
if (status === 'done') return true;
|
|
65
|
+
if (status === 'failed' || status === 'cancelled' || status === 'superseded') return false;
|
|
66
|
+
const claimedAt = Date.parse(row.claimed_at ?? `${row.day}T00:00:00.000+09:00`);
|
|
67
|
+
const withinLease = Number.isFinite(claimedAt) && Date.parse(now) - claimedAt < INTAKE_EVENT_LEASE_MS;
|
|
68
|
+
if (!status) return withinLease;
|
|
69
|
+
// A status alone is not liveness: a crashed worker leaves «running» behind, so require a recent update.
|
|
70
|
+
if (updatedAt !== undefined && Number.isFinite(updatedAt)) return Date.parse(now) - updatedAt < INTAKE_EVENT_TASK_STALE_MS;
|
|
71
|
+
return withinLease;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Claim an event slot before posting. The unique URL and KST-day quota share one SQLite transaction. */
|
|
75
|
+
export async function scheduleIntakeEvent(
|
|
76
|
+
root: string,
|
|
77
|
+
url: string,
|
|
78
|
+
opts: {
|
|
79
|
+
eventMax?: number;
|
|
80
|
+
now?: string;
|
|
81
|
+
post: (input: { url: string }) => Promise<{ taskId: string }>;
|
|
82
|
+
},
|
|
83
|
+
): Promise<IntakeEventResult> {
|
|
84
|
+
const normalized = canonicalUrl(url);
|
|
85
|
+
if (!normalized) throw new Error('intake event requires an HTTP(S) URL');
|
|
86
|
+
const now = opts.now ?? new Date().toISOString();
|
|
87
|
+
const day = kstDay(now);
|
|
88
|
+
const id = intakeItemId('telegram-bot', { url: normalized });
|
|
89
|
+
const max = opts.eventMax ?? 10;
|
|
90
|
+
if (!Number.isSafeInteger(max) || max < 0) throw new RangeError('intake.eventMax must be a non-negative integer');
|
|
91
|
+
const existing = loadIntakeLedger(root).items.get(id);
|
|
92
|
+
if (existing && ['queued', 'absorbed', 'checked', 'routed', 'discarded'].includes(existing.status)) {
|
|
93
|
+
return { status: 'duplicate', url: normalized, reason: 'already-in-ledger' };
|
|
94
|
+
}
|
|
95
|
+
mkdirSync(intakeLedgerDir(root), { recursive: true });
|
|
96
|
+
const db = new Database(join(intakeLedgerDir(root), 'events.sqlite'), { create: true });
|
|
97
|
+
db.exec('PRAGMA busy_timeout = 5000');
|
|
98
|
+
try {
|
|
99
|
+
db.exec(`CREATE TABLE IF NOT EXISTS intake_events (
|
|
100
|
+
url TEXT PRIMARY KEY, day TEXT NOT NULL, status TEXT NOT NULL, task_id TEXT, claimed_at TEXT
|
|
101
|
+
)`);
|
|
102
|
+
const columns = db.query('PRAGMA table_info(intake_events)').all() as { name: string }[];
|
|
103
|
+
if (!columns.some((column) => column.name === 'claimed_at')) db.exec('ALTER TABLE intake_events ADD COLUMN claimed_at TEXT');
|
|
104
|
+
// One row per claim that reached the task queue — the KST-day quota counts these, not current statuses.
|
|
105
|
+
db.exec('CREATE TABLE IF NOT EXISTS intake_event_claims (url TEXT NOT NULL, day TEXT NOT NULL, claimed_at TEXT NOT NULL, PRIMARY KEY (url, claimed_at))');
|
|
106
|
+
const claim = db.transaction((): 'scheduled' | 'deferred' | 'duplicate' => {
|
|
107
|
+
const previous = db.query('SELECT status, task_id, claimed_at, day FROM intake_events WHERE url = ?').get(normalized) as EventClaim | null;
|
|
108
|
+
if (previous && activeEventClaim(previous, now, eventTaskStatus)) {
|
|
109
|
+
if (previous.task_id && eventTaskStatus(previous.task_id)?.status === 'done') {
|
|
110
|
+
db.query("UPDATE intake_events SET status = 'completed' WHERE url = ?").run(normalized);
|
|
111
|
+
}
|
|
112
|
+
return 'duplicate';
|
|
113
|
+
}
|
|
114
|
+
if (previous && previous.status !== 'failed' && previous.status !== 'expired'
|
|
115
|
+
&& previous.status !== 'pending' && previous.status !== 'scheduled') {
|
|
116
|
+
return previous.status === 'deferred' ? 'deferred' : 'duplicate';
|
|
117
|
+
}
|
|
118
|
+
const count = db.query('SELECT count(*) AS n FROM intake_event_claims WHERE day = ?').get(day) as { n: number };
|
|
119
|
+
const status = count.n >= max ? 'deferred' : 'pending';
|
|
120
|
+
db.query(`INSERT INTO intake_events (url, day, status, claimed_at) VALUES (?, ?, ?, ?)
|
|
121
|
+
ON CONFLICT(url) DO UPDATE SET day = excluded.day, status = excluded.status, claimed_at = excluded.claimed_at, task_id = NULL`)
|
|
122
|
+
.run(normalized, day, status, now);
|
|
123
|
+
if (status === 'pending') db.query('INSERT OR IGNORE INTO intake_event_claims (url, day, claimed_at) VALUES (?, ?, ?)').run(normalized, day, now);
|
|
124
|
+
return status === 'pending' ? 'scheduled' : 'deferred';
|
|
125
|
+
})();
|
|
126
|
+
if (claim === 'duplicate') return { status: 'duplicate', url: normalized, reason: 'already-claimed' };
|
|
127
|
+
if (claim === 'deferred') {
|
|
128
|
+
ingestIntakeItems(root, 'telegram-bot', [{ url: normalized }], now);
|
|
129
|
+
debug.log('intake.event', 'deferred', { url: normalized, reason: 'event-max' });
|
|
130
|
+
return { status: 'deferred', url: normalized, reason: 'event-max', reply: INTAKE_EVENT_DEFERRED_REPLY };
|
|
131
|
+
}
|
|
132
|
+
let posted: { taskId: string };
|
|
133
|
+
try {
|
|
134
|
+
ingestIntakeItems(root, 'telegram-bot', [{ url: normalized }], now);
|
|
135
|
+
posted = await opts.post({ url: normalized });
|
|
136
|
+
if (!posted.taskId) throw new Error('intake event post returned no taskId');
|
|
137
|
+
} catch (error) {
|
|
138
|
+
db.query('DELETE FROM intake_events WHERE url = ? AND status = ? AND claimed_at = ?').run(normalized, 'pending', now);
|
|
139
|
+
db.query('DELETE FROM intake_event_claims WHERE url = ? AND claimed_at = ?').run(normalized, now);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
db.query("UPDATE intake_events SET status = 'scheduled', task_id = ? WHERE url = ? AND status = 'pending' AND claimed_at = ?")
|
|
143
|
+
.run(posted.taskId, normalized, now);
|
|
144
|
+
debug.log('intake.event', 'scheduled', { url: normalized, reason: 'telegram-absorb' });
|
|
145
|
+
return { status: 'scheduled', url: normalized, reason: 'telegram-absorb' };
|
|
146
|
+
} finally {
|
|
147
|
+
db.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Only an active task or a fresh claim excludes a URL from the daily queue. */
|
|
152
|
+
export function wasIntakeEventScheduled(root: string, url: string, now = new Date().toISOString(), taskStatus: IntakeEventTaskProbe = eventTaskStatus): boolean {
|
|
153
|
+
const file = join(intakeLedgerDir(root), 'events.sqlite');
|
|
154
|
+
if (!existsSync(file)) return false;
|
|
155
|
+
const normalized = canonicalUrl(url);
|
|
156
|
+
if (!normalized) return false;
|
|
157
|
+
const db = new Database(file);
|
|
158
|
+
try {
|
|
159
|
+
const row = db.query('SELECT status, task_id, claimed_at, day FROM intake_events WHERE url = ?').get(normalized) as EventClaim | null;
|
|
160
|
+
if (!row || (row.status !== 'scheduled' && row.status !== 'pending')) return row?.status === 'completed';
|
|
161
|
+
if (activeEventClaim(row, now, taskStatus)) {
|
|
162
|
+
if (row.task_id && probeStatus(taskStatus, row.task_id).status === 'done') {
|
|
163
|
+
db.query("UPDATE intake_events SET status = 'completed' WHERE url = ?").run(normalized);
|
|
164
|
+
}
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
const status = row.task_id ? probeStatus(taskStatus, row.task_id).status : undefined;
|
|
168
|
+
db.query("UPDATE intake_events SET status = ? WHERE url = ? AND status IN ('pending', 'scheduled')")
|
|
169
|
+
.run(status === 'failed' || status === 'cancelled' || status === 'superseded' ? 'failed' : 'expired', normalized);
|
|
170
|
+
return false;
|
|
171
|
+
} finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
32
176
|
function appendJsonl(file: string, rows: unknown[]): void {
|
|
33
177
|
if (!rows.length) return;
|
|
34
178
|
mkdirSync(join(file, '..'), { recursive: true });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { Database } from 'bun:sqlite';
|
|
3
|
+
|
|
4
|
+
const [dbPath, releasedMarker] = process.argv.slice(2);
|
|
5
|
+
if (!dbPath || !releasedMarker) throw new Error('lock fixture requires database path and marker');
|
|
6
|
+
const db = new Database(dbPath);
|
|
7
|
+
try {
|
|
8
|
+
db.exec('BEGIN IMMEDIATE');
|
|
9
|
+
console.log('LOCKED');
|
|
10
|
+
await Bun.sleep(500);
|
|
11
|
+
writeFileSync(releasedMarker, 'released');
|
|
12
|
+
db.exec('COMMIT');
|
|
13
|
+
} finally {
|
|
14
|
+
db.close();
|
|
15
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Database } from 'bun:sqlite';
|
|
5
|
+
import { effectiveInstanceRoot } from '../instance/resolve.js';
|
|
6
|
+
import type { QueueSeat } from '../harness/harness-queue.js';
|
|
7
|
+
|
|
8
|
+
export type SeatGoalStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'expired';
|
|
9
|
+
export type SeatGoal = {
|
|
10
|
+
id: string;
|
|
11
|
+
seat: QueueSeat;
|
|
12
|
+
goal: string;
|
|
13
|
+
prerequisites: string[];
|
|
14
|
+
notBefore?: string;
|
|
15
|
+
endAt?: string;
|
|
16
|
+
queuedAt: string;
|
|
17
|
+
status: SeatGoalStatus;
|
|
18
|
+
startedAt?: string;
|
|
19
|
+
finishedAt?: string;
|
|
20
|
+
};
|
|
21
|
+
export type SeatGoalInput = {
|
|
22
|
+
id?: string;
|
|
23
|
+
seat: QueueSeat;
|
|
24
|
+
goal: string;
|
|
25
|
+
prerequisites?: string[];
|
|
26
|
+
notBefore?: string;
|
|
27
|
+
endAt?: string;
|
|
28
|
+
};
|
|
29
|
+
export type SeatGoalQueueOptions = { root?: string; now?: () => Date };
|
|
30
|
+
export type SeatGoalScheduleOptions = SeatGoalQueueOptions & {
|
|
31
|
+
maxConcurrent: number;
|
|
32
|
+
seatCaps: Partial<Record<QueueSeat, number>>;
|
|
33
|
+
launch: (goal: SeatGoal) => Promise<void> | void;
|
|
34
|
+
};
|
|
35
|
+
export type SeatGoalScheduleResult = { outcome: 'started' | 'waiting' | 'empty'; goal?: SeatGoal; reason: string };
|
|
36
|
+
|
|
37
|
+
const SEATS = new Set(['OP', 'TC', 'MK', 'UX']);
|
|
38
|
+
const TERMINAL = new Set<SeatGoalStatus>(['succeeded', 'failed', 'expired']);
|
|
39
|
+
|
|
40
|
+
export function seatGoalQueuePath(root = effectiveInstanceRoot()): string {
|
|
41
|
+
return join(root, 'launch-head', 'seat-goals.sqlite');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function openQueue(root: string): Database {
|
|
45
|
+
const path = seatGoalQueuePath(root);
|
|
46
|
+
mkdirSync(join(root, 'launch-head'), { recursive: true, mode: 0o700 });
|
|
47
|
+
const db = new Database(path, { create: true, strict: true });
|
|
48
|
+
db.exec('PRAGMA busy_timeout = 30000');
|
|
49
|
+
db.exec(`CREATE TABLE IF NOT EXISTS seat_goals (
|
|
50
|
+
id TEXT PRIMARY KEY, seat TEXT NOT NULL, goal TEXT NOT NULL, prerequisites TEXT NOT NULL,
|
|
51
|
+
not_before TEXT, end_at TEXT, queued_at TEXT NOT NULL, status TEXT NOT NULL,
|
|
52
|
+
started_at TEXT, finished_at TEXT
|
|
53
|
+
)`);
|
|
54
|
+
return db;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type GoalRow = {
|
|
58
|
+
id: string; seat: QueueSeat; goal: string; prerequisites: string;
|
|
59
|
+
not_before: string | null; end_at: string | null; queued_at: string;
|
|
60
|
+
status: SeatGoalStatus; started_at: string | null; finished_at: string | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function toGoal(row: GoalRow): SeatGoal {
|
|
64
|
+
return {
|
|
65
|
+
id: row.id, seat: row.seat, goal: row.goal, prerequisites: JSON.parse(row.prerequisites) as string[],
|
|
66
|
+
queuedAt: row.queued_at, status: row.status,
|
|
67
|
+
...(row.not_before ? { notBefore: row.not_before } : {}),
|
|
68
|
+
...(row.end_at ? { endAt: row.end_at } : {}),
|
|
69
|
+
...(row.started_at ? { startedAt: row.started_at } : {}),
|
|
70
|
+
...(row.finished_at ? { finishedAt: row.finished_at } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rows(db: Database): SeatGoal[] {
|
|
75
|
+
return (db.query('SELECT * FROM seat_goals ORDER BY queued_at, rowid').all() as GoalRow[]).map(toGoal);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function instant(value: string, field: string): number {
|
|
79
|
+
const time = Date.parse(value);
|
|
80
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) throw new Error(`seat goal queue: invalid ${field}`);
|
|
81
|
+
return time;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clock(now?: () => Date): Date {
|
|
85
|
+
const date = now?.() ?? new Date();
|
|
86
|
+
if (!Number.isFinite(date.getTime())) throw new Error('seat goal queue: invalid clock');
|
|
87
|
+
return date;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cap(value: number, field: string): number {
|
|
91
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`seat goal queue: invalid ${field}`);
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The launcher owns this store; producers enqueue only, never start a goal. */
|
|
96
|
+
export function enqueueSeatGoal(input: SeatGoalInput, options: SeatGoalQueueOptions = {}): SeatGoal {
|
|
97
|
+
if (!SEATS.has(input.seat)) throw new Error('seat goal queue: invalid seat');
|
|
98
|
+
if (!input.goal?.trim()) throw new Error('seat goal queue: empty goal');
|
|
99
|
+
const id = input.id ?? `sg-${randomUUID()}`;
|
|
100
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(id)) throw new Error('seat goal queue: invalid id');
|
|
101
|
+
const prerequisites = input.prerequisites ?? [];
|
|
102
|
+
if (!Array.isArray(prerequisites) || prerequisites.some((key) => typeof key !== 'string' || !key.trim())
|
|
103
|
+
|| new Set(prerequisites).size !== prerequisites.length || prerequisites.includes(id)) {
|
|
104
|
+
throw new Error('seat goal queue: invalid prerequisites');
|
|
105
|
+
}
|
|
106
|
+
const start = input.notBefore === undefined ? undefined : instant(input.notBefore, 'notBefore');
|
|
107
|
+
const end = input.endAt === undefined ? undefined : instant(input.endAt, 'endAt');
|
|
108
|
+
if (start !== undefined && end !== undefined && end <= start) throw new Error('seat goal queue: endAt must follow notBefore');
|
|
109
|
+
const queuedAt = clock(options.now).toISOString();
|
|
110
|
+
if (end !== undefined && end <= Date.parse(queuedAt)) throw new Error('seat goal queue: endAt has passed');
|
|
111
|
+
const db = openQueue(options.root ?? effectiveInstanceRoot());
|
|
112
|
+
try {
|
|
113
|
+
db.exec('BEGIN IMMEDIATE');
|
|
114
|
+
try {
|
|
115
|
+
const all = rows(db);
|
|
116
|
+
if (all.some((row) => row.id === id)) throw new Error(`seat goal queue: duplicate id ${id}`);
|
|
117
|
+
for (const key of prerequisites) {
|
|
118
|
+
if (!all.some((row) => row.id === key)) throw new Error(`seat goal queue: missing prerequisite ${key}`);
|
|
119
|
+
}
|
|
120
|
+
db.query(`INSERT INTO seat_goals (id, seat, goal, prerequisites, not_before, end_at, queued_at, status)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')`).run(id, input.seat, input.goal, JSON.stringify(prerequisites),
|
|
122
|
+
input.notBefore ?? null, input.endAt ?? null, queuedAt);
|
|
123
|
+
db.exec('COMMIT');
|
|
124
|
+
return { id, seat: input.seat, goal: input.goal, prerequisites: [...prerequisites], queuedAt, status: 'queued',
|
|
125
|
+
...(input.notBefore ? { notBefore: input.notBefore } : {}), ...(input.endAt ? { endAt: input.endAt } : {}) };
|
|
126
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
127
|
+
} finally { db.close(); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function listSeatGoals(options: SeatGoalQueueOptions = {}): SeatGoal[] {
|
|
131
|
+
const db = openQueue(options.root ?? effectiveInstanceRoot());
|
|
132
|
+
try { return rows(db); } finally { db.close(); }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Completion is an explicit launcher receipt: an uncertain or crashed launch keeps its slot reserved. */
|
|
136
|
+
export function finishSeatGoal(id: string, status: 'succeeded' | 'failed', options: SeatGoalQueueOptions = {}): SeatGoal {
|
|
137
|
+
const db = openQueue(options.root ?? effectiveInstanceRoot());
|
|
138
|
+
try {
|
|
139
|
+
const finishedAt = clock(options.now).toISOString();
|
|
140
|
+
const result = db.query("UPDATE seat_goals SET status = ?, finished_at = ? WHERE id = ? AND status = 'running'")
|
|
141
|
+
.run(status, finishedAt, id);
|
|
142
|
+
if (result.changes !== 1) throw new Error(`seat goal queue: goal is not running: ${id}`);
|
|
143
|
+
return toGoal(db.query('SELECT * FROM seat_goals WHERE id = ?').get(id) as GoalRow);
|
|
144
|
+
} finally { db.close(); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** One tick claims at most one eligible goal atomically across launcher processes. */
|
|
148
|
+
export async function scheduleSeatGoals(options: SeatGoalScheduleOptions): Promise<SeatGoalScheduleResult> {
|
|
149
|
+
const totalLimit = cap(options.maxConcurrent, 'maxConcurrent');
|
|
150
|
+
for (const [seat, value] of Object.entries(options.seatCaps)) {
|
|
151
|
+
if (!SEATS.has(seat) || value === undefined) throw new Error('seat goal queue: invalid seatCaps');
|
|
152
|
+
cap(value, `seatCaps.${seat}`);
|
|
153
|
+
}
|
|
154
|
+
const db = openQueue(options.root ?? effectiveInstanceRoot());
|
|
155
|
+
let selected: SeatGoal | undefined;
|
|
156
|
+
let reason = 'empty';
|
|
157
|
+
try {
|
|
158
|
+
db.exec('BEGIN IMMEDIATE');
|
|
159
|
+
try {
|
|
160
|
+
const at = clock(options.now);
|
|
161
|
+
const all = rows(db);
|
|
162
|
+
const byId = new Map(all.map((goal) => [goal.id, goal]));
|
|
163
|
+
for (const goal of all) {
|
|
164
|
+
if (goal.status !== 'queued') continue;
|
|
165
|
+
const failed = goal.prerequisites.some((id) => {
|
|
166
|
+
const prerequisite = byId.get(id);
|
|
167
|
+
return !prerequisite || (TERMINAL.has(prerequisite.status) && prerequisite.status !== 'succeeded');
|
|
168
|
+
});
|
|
169
|
+
if (failed || (goal.endAt && Date.parse(goal.endAt) <= at.getTime())) {
|
|
170
|
+
db.query("UPDATE seat_goals SET status = 'expired', finished_at = ? WHERE id = ? AND status = 'queued'")
|
|
171
|
+
.run(at.toISOString(), goal.id);
|
|
172
|
+
goal.status = 'expired';
|
|
173
|
+
goal.finishedAt = at.toISOString();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const running = all.filter((goal) => goal.status === 'running');
|
|
177
|
+
for (const goal of all) {
|
|
178
|
+
if (goal.status !== 'queued') continue;
|
|
179
|
+
if (goal.notBefore && Date.parse(goal.notBefore) > at.getTime()) { reason = 'not-before'; continue; }
|
|
180
|
+
if (goal.prerequisites.some((id) => byId.get(id)?.status !== 'succeeded')) { reason = 'prerequisites'; continue; }
|
|
181
|
+
if (running.length >= totalLimit) { reason = 'global-cap'; continue; }
|
|
182
|
+
if (running.filter((row) => row.seat === goal.seat).length >= (options.seatCaps[goal.seat] ?? totalLimit)) {
|
|
183
|
+
reason = 'seat-cap'; continue;
|
|
184
|
+
}
|
|
185
|
+
const startedAt = at.toISOString();
|
|
186
|
+
selected = { ...goal, status: 'running', startedAt };
|
|
187
|
+
db.query("UPDATE seat_goals SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'")
|
|
188
|
+
.run(startedAt, goal.id);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
db.exec('COMMIT');
|
|
192
|
+
if (!selected) return { outcome: all.some((goal) => goal.status === 'queued') ? 'waiting' : 'empty', reason };
|
|
193
|
+
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
194
|
+
} finally { db.close(); }
|
|
195
|
+
// Keep the claim on an uncertain launch. Only a positive completion receipt can release it.
|
|
196
|
+
await options.launch(selected);
|
|
197
|
+
return { outcome: 'started', goal: selected, reason: 'launched' };
|
|
198
|
+
}
|
package/src/loops/budget.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface SpawnBudgetInput {
|
|
|
8
8
|
caps?: Partial<Record<OrchestratorSeat, number>>;
|
|
9
9
|
/** Release-gate seat limits. Missing seats use the gate default of four. */
|
|
10
10
|
gate?: Partial<Record<OrchestratorSeat, number>>;
|
|
11
|
+
/** Optional queue-injected cap; it may lower, never replace, the configured limit. */
|
|
12
|
+
injectedCap?: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface SpawnBudgetDecision {
|
|
@@ -16,13 +18,42 @@ export interface SpawnBudgetDecision {
|
|
|
16
18
|
cap: number;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
export type SeatCapDetails = {
|
|
22
|
+
cap: number;
|
|
23
|
+
seatCaps: number | '?';
|
|
24
|
+
releaseGate: number | '?';
|
|
25
|
+
injectedCap?: number | '?';
|
|
26
|
+
winners: ('releaseGate' | 'seatCaps' | 'injectedCap')[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Keep the cap calculation and the names shown to operators on the same source of truth. */
|
|
30
|
+
export function seatCapDetails({ seat, caps, gate, injectedCap }: Pick<SpawnBudgetInput, 'seat' | 'caps' | 'gate' | 'injectedCap'>): SeatCapDetails {
|
|
21
31
|
const configured = caps?.[seat] ?? ORCHESTRATOR_DEFAULTS.seatCaps[seat];
|
|
22
32
|
const gated = gate?.[seat] ?? 4;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
33
|
+
const valid = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
|
|
34
|
+
const seatCaps = valid(configured) ? configured : '?';
|
|
35
|
+
const releaseGate = valid(gated) ? gated : '?';
|
|
36
|
+
const injected = injectedCap === undefined ? undefined : valid(injectedCap) ? injectedCap : '?';
|
|
37
|
+
const cap = [seatCaps, releaseGate, injected].some((value) => value === '?') ? 0
|
|
38
|
+
: Math.min(seatCaps as number, releaseGate as number, injected === undefined ? Infinity : injected as number);
|
|
39
|
+
const winners: SeatCapDetails['winners'] = [];
|
|
40
|
+
if (seatCaps !== '?' && releaseGate !== '?' && injected !== '?') {
|
|
41
|
+
if (releaseGate === cap) winners.push('releaseGate');
|
|
42
|
+
if (seatCaps === cap) winners.push('seatCaps');
|
|
43
|
+
if (injected === cap) winners.push('injectedCap');
|
|
44
|
+
}
|
|
45
|
+
return { cap, seatCaps, releaseGate, ...(injected === undefined ? {} : { injectedCap: injected }), winners };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function seatCapReason(seat: OrchestratorSeat, active: number, details: SeatCapDetails): string {
|
|
49
|
+
const keys = (['releaseGate', 'seatCaps', 'injectedCap'] as const).filter((name) => name !== 'injectedCap' || details.injectedCap !== undefined);
|
|
50
|
+
const candidates = [...details.winners, ...keys.filter((name) => !details.winners.includes(name))];
|
|
51
|
+
return `seat ${seat}: ${active}/${details.cap} (${candidates.map((name) => `${name}.${seat}=${details[name]}`).join(' · ')})`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A release gate or injected queue cap can lower, but never raise, the configured per-seat cap. */
|
|
55
|
+
export function decideSpawn({ seat, running, caps, gate, injectedCap }: SpawnBudgetInput): SpawnBudgetDecision {
|
|
56
|
+
const { cap } = seatCapDetails({ seat, caps, gate, injectedCap });
|
|
26
57
|
if (!Number.isSafeInteger(running) || running === null || running === undefined || running < 0) {
|
|
27
58
|
return { allow: false, reason: 'unknown-running', cap };
|
|
28
59
|
}
|
package/src/loops/checker.ts
CHANGED
|
@@ -14,8 +14,8 @@ export type LoopScope = 'graph-only' | 'registry';
|
|
|
14
14
|
export interface CheckEntry {
|
|
15
15
|
id: string;
|
|
16
16
|
owner?: string;
|
|
17
|
-
ownerSource?: 'header' | 'config' | 'seat' | 'default';
|
|
18
|
-
evidence?: 'log-mtime';
|
|
17
|
+
ownerSource?: 'header' | 'config' | 'seat' | 'seat-arg' | 'default';
|
|
18
|
+
evidence?: 'log-mtime' | 'registry';
|
|
19
19
|
mode?: string;
|
|
20
20
|
expectEveryMinutes?: number;
|
|
21
21
|
/** The scheduled fire before the latest one (cron loops). A run older than this missed two scheduled fires. */
|