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
|
@@ -30,6 +30,18 @@ export function releasedVersion(ledgerRoot = releaseLedgerRoot()): string {
|
|
|
30
30
|
})[0] ?? '';
|
|
31
31
|
}
|
|
32
32
|
const walWait = new Int32Array(new SharedArrayBuffer(4));
|
|
33
|
+
const SCHEMA_COLUMNS: Record<string, readonly string[]> = {
|
|
34
|
+
imported_versions: ['json_hash', 'imported_at'],
|
|
35
|
+
events: ['reason'],
|
|
36
|
+
assignments: ['priority', 'predecessors', 'deadline_version', 'ceo_minutes', 'ceo_date'],
|
|
37
|
+
release_schedules: ['freeze_from', 'freeze_until'],
|
|
38
|
+
};
|
|
39
|
+
function schemaCurrent(db: Database): boolean {
|
|
40
|
+
return Object.entries(SCHEMA_COLUMNS).every(([table, names]) => {
|
|
41
|
+
const columns = new Set((db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((column) => column.name));
|
|
42
|
+
return names.every((name) => columns.has(name));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
33
45
|
function open(root = releaseLedgerRoot()): Database {
|
|
34
46
|
const path = join(root, 'release', 'features.sqlite');
|
|
35
47
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -48,22 +60,27 @@ function open(root = releaseLedgerRoot()): Database {
|
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
db.exec('PRAGMA foreign_keys = ON');
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (!
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
// Two processes opening a fresh ledger both saw a missing column and both ran ALTER TABLE ("duplicate column name").
|
|
64
|
+
// BEGIN IMMEDIATE serializes the schema check and the migration, so the second writer re-reads the migrated schema.
|
|
65
|
+
// An up-to-date ledger skips the write lock so readers don't queue behind each other.
|
|
66
|
+
if (!schemaCurrent(db)) db.transaction(() => {
|
|
67
|
+
if (!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imported_versions'").get()) db.exec(`CREATE TABLE IF NOT EXISTS features (id TEXT PRIMARY KEY, title TEXT NOT NULL, owner TEXT, kind TEXT, created_at TEXT NOT NULL);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS assignments (feature_id TEXT NOT NULL REFERENCES features(id), version TEXT NOT NULL, status TEXT NOT NULL, disposition TEXT, evidence TEXT, title_override TEXT, owner TEXT, kind TEXT, updated_at TEXT NOT NULL, updated_by TEXT NOT NULL, PRIMARY KEY(feature_id, version));
|
|
69
|
+
CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, by TEXT NOT NULL, feature_id TEXT NOT NULL, version TEXT NOT NULL, field TEXT NOT NULL, "from" TEXT, "to" TEXT, released TEXT NOT NULL, dev TEXT NOT NULL, reason TEXT);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS evidence (feature_id TEXT NOT NULL, version TEXT NOT NULL, ref TEXT NOT NULL, at TEXT NOT NULL, by TEXT NOT NULL);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS imported_versions (version TEXT PRIMARY KEY, json_hash TEXT, imported_at TEXT);`);
|
|
72
|
+
const columns = db.query('PRAGMA table_info(imported_versions)').all() as Array<{ name: string }>;
|
|
73
|
+
if (!columns.some((column) => column.name === 'json_hash')) db.exec('ALTER TABLE imported_versions ADD COLUMN json_hash TEXT');
|
|
74
|
+
if (!columns.some((column) => column.name === 'imported_at')) db.exec('ALTER TABLE imported_versions ADD COLUMN imported_at TEXT');
|
|
75
|
+
if (!(db.query('PRAGMA table_info(events)').all() as Array<{ name: string }>).some((column) => column.name === 'reason')) db.exec('ALTER TABLE events ADD COLUMN reason TEXT');
|
|
76
|
+
db.exec('CREATE TABLE IF NOT EXISTS release_schedules (version TEXT PRIMARY KEY, cut_at TEXT NOT NULL, land_by TEXT, updated_at TEXT NOT NULL, updated_by TEXT NOT NULL)');
|
|
77
|
+
const assignmentColumns = db.query('PRAGMA table_info(assignments)').all() as Array<{ name: string }>;
|
|
78
|
+
for (const [name, sql] of [['priority', 'TEXT'], ['predecessors', 'TEXT'], ['deadline_version', 'TEXT'], ['ceo_minutes', 'INTEGER'], ['ceo_date', 'TEXT']] as const) {
|
|
79
|
+
if (!assignmentColumns.some((column) => column.name === name)) db.exec(`ALTER TABLE assignments ADD COLUMN ${name} ${sql}`);
|
|
80
|
+
}
|
|
81
|
+
const scheduleColumns = db.query('PRAGMA table_info(release_schedules)').all() as Array<{ name: string }>;
|
|
82
|
+
for (const name of ['freeze_from', 'freeze_until']) if (!scheduleColumns.some((column) => column.name === name)) db.exec(`ALTER TABLE release_schedules ADD COLUMN ${name} TEXT`);
|
|
83
|
+
}).immediate();
|
|
67
84
|
return db;
|
|
68
85
|
} catch (error) { db.close(); throw error; }
|
|
69
86
|
}
|
|
@@ -90,6 +107,14 @@ export function readSchedules(root?: string): ScheduleRow[] {
|
|
|
90
107
|
finally { db.close(); }
|
|
91
108
|
}
|
|
92
109
|
|
|
110
|
+
/** Versions with checklist assignments, including SQLite-only releases without a schedule or legacy JSON. */
|
|
111
|
+
export function assignedVersions(root = releaseLedgerRoot()): string[] {
|
|
112
|
+
if (!existsSync(join(root, 'release', 'features.sqlite'))) return [];
|
|
113
|
+
const db = open(root);
|
|
114
|
+
try { return (db.query('SELECT DISTINCT version FROM assignments ORDER BY version').all() as Array<{ version: string }>).map(({ version }) => version); }
|
|
115
|
+
finally { db.close(); }
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
export function writeSchedule(version: string, patch: { cutAt?: string; landBy?: string; freezeFrom?: string; freezeUntil?: string }, by: string, root?: string): ScheduleRow {
|
|
94
119
|
validateVersion(version);
|
|
95
120
|
const db = open(root);
|
|
@@ -144,16 +169,17 @@ function evidenceRefs(db: Database, version: string, id: string): string[] {
|
|
|
144
169
|
return (db.query('SELECT ref FROM evidence WHERE feature_id = ? AND version = ? ORDER BY rowid').all(id, version) as Array<{ ref: string }>).map((row) => row.ref);
|
|
145
170
|
}
|
|
146
171
|
function items(db: Database, version: string): ChecklistItem[] {
|
|
147
|
-
const rows = db.query(`SELECT f.id, COALESCE(a.title_override, f.title) AS title, a.owner, a.kind, a.priority, a.predecessors, a.deadline_version, a.status, a.disposition, a.evidence, a.updated_at, a.updated_by
|
|
148
|
-
FROM assignments a JOIN features f ON f.id = a.feature_id WHERE a.version = ? ORDER BY a.rowid`).all(version) as Array<Record<string, string | null>>;
|
|
172
|
+
const rows = db.query(`SELECT f.id, COALESCE(a.title_override, f.title) AS title, a.owner, a.kind, a.priority, a.predecessors, a.deadline_version, a.ceo_minutes, a.ceo_date, a.status, a.disposition, a.evidence, a.updated_at, a.updated_by
|
|
173
|
+
FROM assignments a JOIN features f ON f.id = a.feature_id WHERE a.version = ? ORDER BY a.rowid`).all(version) as Array<Record<string, string | number | null>>;
|
|
149
174
|
return rows.map((r) => {
|
|
150
|
-
const evidence = [r.evidence, ...evidenceRefs(db, version, r.id
|
|
151
|
-
return { id: r.id
|
|
152
|
-
...(r.owner !== null ? { owner: r.owner
|
|
153
|
-
...(r.priority !== null ? { priority: r.priority as ChecklistItem['priority'] } : {}), ...(r.predecessors !== null ? { predecessors: JSON.parse(r.predecessors
|
|
154
|
-
...(r.deadline_version !== null ? { deadlineVersion: r.deadline_version
|
|
175
|
+
const evidence = [r.evidence, ...evidenceRefs(db, version, r.id as string)].filter((value) => value !== null).join('\n');
|
|
176
|
+
return { id: r.id as string, title: r.title as string, status: r.status as ChecklistItem['status'],
|
|
177
|
+
...(r.owner !== null ? { owner: r.owner as string } : {}), ...(r.kind !== null ? { kind: r.kind as ChecklistItem['kind'] } : {}),
|
|
178
|
+
...(r.priority !== null ? { priority: r.priority as ChecklistItem['priority'] } : {}), ...(r.predecessors !== null ? { predecessors: JSON.parse(r.predecessors as string) as string[] } : {}),
|
|
179
|
+
...(r.deadline_version !== null ? { deadlineVersion: r.deadline_version as string } : {}),
|
|
180
|
+
...(r.ceo_minutes !== null ? { ceoMinutes: r.ceo_minutes as number } : {}), ...(r.ceo_date !== null ? { ceoDate: r.ceo_date as string } : {}),
|
|
155
181
|
...(r.evidence !== null || evidence ? { evidence } : {}), ...(r.disposition !== null ? { disposition: r.disposition as ChecklistItem['disposition'] } : {}),
|
|
156
|
-
updatedAt: r.updated_at
|
|
182
|
+
updatedAt: r.updated_at as string, updatedBy: r.updated_by as string };
|
|
157
183
|
});
|
|
158
184
|
}
|
|
159
185
|
function snapshot(db: Database, version: string, released: string, dev: string): Checklist {
|
|
@@ -165,9 +191,9 @@ function putItem(db: Database, version: string, item: ChecklistItem): void {
|
|
|
165
191
|
if (!feature) db.query('INSERT INTO features (id, title, owner, kind, created_at) VALUES (?, ?, ?, ?, ?)').run(item.id, item.title, item.owner ?? null, item.kind ?? null, item.updatedAt);
|
|
166
192
|
// features.owner/kind follow the latest assignment so details() and the checklist never disagree after a set.
|
|
167
193
|
else if (item.owner !== undefined || item.kind !== undefined) db.query('UPDATE features SET owner = COALESCE(?, owner), kind = COALESCE(?, kind) WHERE id = ?').run(item.owner ?? null, item.kind ?? null, item.id);
|
|
168
|
-
db.query(`INSERT INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, priority, predecessors, deadline_version, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
169
|
-
ON CONFLICT(feature_id, version) DO UPDATE SET status=excluded.status, disposition=excluded.disposition, evidence=excluded.evidence, owner=excluded.owner, kind=excluded.kind, priority=excluded.priority, predecessors=excluded.predecessors, deadline_version=excluded.deadline_version, updated_at=excluded.updated_at, updated_by=excluded.updated_by`)
|
|
170
|
-
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, feature && feature.title !== item.title ? item.title : null, item.owner ?? null, item.kind ?? null, item.priority ?? null, item.predecessors ? JSON.stringify(item.predecessors) : null, item.deadlineVersion ?? null, item.updatedAt, item.updatedBy);
|
|
194
|
+
db.query(`INSERT INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, priority, predecessors, deadline_version, ceo_minutes, ceo_date, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(feature_id, version) DO UPDATE SET status=excluded.status, disposition=excluded.disposition, evidence=excluded.evidence, owner=excluded.owner, kind=excluded.kind, priority=excluded.priority, predecessors=excluded.predecessors, deadline_version=excluded.deadline_version, ceo_minutes=excluded.ceo_minutes, ceo_date=excluded.ceo_date, updated_at=excluded.updated_at, updated_by=excluded.updated_by`)
|
|
196
|
+
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, feature && feature.title !== item.title ? item.title : null, item.owner ?? null, item.kind ?? null, item.priority ?? null, item.predecessors ? JSON.stringify(item.predecessors) : null, item.deadlineVersion ?? null, item.ceoMinutes ?? null, item.ceoDate ?? null, item.updatedAt, item.updatedBy);
|
|
171
197
|
}
|
|
172
198
|
function jsonHash(contents: string | Buffer): string { return createHash('sha256').update(contents).digest('hex'); }
|
|
173
199
|
|
|
@@ -208,8 +234,8 @@ function importOnDb(db: Database, version: string): boolean {
|
|
|
208
234
|
// Keep a legacy per-version title until an explicit retitle unifies the feature identity.
|
|
209
235
|
if (!existing) putItem(db, version, item);
|
|
210
236
|
else {
|
|
211
|
-
db.query('INSERT OR IGNORE INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, priority, predecessors, deadline_version, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
212
|
-
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, item.title === existing.title ? null : item.title, item.owner ?? null, item.kind ?? null, item.priority ?? null, item.predecessors ? JSON.stringify(item.predecessors) : null, item.deadlineVersion ?? null, item.updatedAt, item.updatedBy);
|
|
237
|
+
db.query('INSERT OR IGNORE INTO assignments (feature_id, version, status, disposition, evidence, title_override, owner, kind, priority, predecessors, deadline_version, ceo_minutes, ceo_date, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
238
|
+
.run(item.id, version, item.status, item.disposition ?? null, item.evidence ?? null, item.title === existing.title ? null : item.title, item.owner ?? null, item.kind ?? null, item.priority ?? null, item.predecessors ? JSON.stringify(item.predecessors) : null, item.deadlineVersion ?? null, item.ceoMinutes ?? null, item.ceoDate ?? null, item.updatedAt, item.updatedBy);
|
|
213
239
|
}
|
|
214
240
|
}
|
|
215
241
|
for (const entry of data.history) record(db, version, entry);
|
|
@@ -236,7 +262,7 @@ function importOnDb(db: Database, version: string): boolean {
|
|
|
236
262
|
record(db, version, { at: new Date().toISOString(), by: item.updatedBy, id: item.id, field: 'reimport', from: null, to: item, released: data.released, dev: data.dev });
|
|
237
263
|
continue;
|
|
238
264
|
}
|
|
239
|
-
const fields = ['title', 'status', 'evidence', 'owner', 'disposition', 'kind', 'priority', 'predecessors', 'deadlineVersion'] as const;
|
|
265
|
+
const fields = ['title', 'status', 'evidence', 'owner', 'disposition', 'kind', 'priority', 'predecessors', 'deadlineVersion', 'ceoMinutes', 'ceoDate'] as const;
|
|
240
266
|
const baseEvidence = (db.query('SELECT evidence FROM assignments WHERE feature_id = ? AND version = ?').get(item.id, version) as { evidence: string | null }).evidence ?? undefined;
|
|
241
267
|
const preserveRefs = item.evidence === baseEvidence || item.evidence === current.evidence;
|
|
242
268
|
const differs = fields.some((field) => field === 'evidence'
|
|
@@ -381,7 +407,7 @@ export function remove(version: string, id: string, by: string, released = relea
|
|
|
381
407
|
return true;
|
|
382
408
|
});
|
|
383
409
|
}
|
|
384
|
-
export function move(id: string, from: string, to: string, by: string, released = releasedVersion(), dev = devVersion(), reason?: string): Checklist {
|
|
410
|
+
export function move(id: string, from: string, to: string, by: string, released = releasedVersion(), dev = devVersion(), reason?: string, options: { clearDisposition?: boolean } = {}): Checklist {
|
|
385
411
|
validateVersion(from); validateVersion(to);
|
|
386
412
|
if (from === to) throw new CliUserError('같은 판으로 옮길 수 없다');
|
|
387
413
|
const db = open();
|
|
@@ -395,6 +421,7 @@ export function move(id: string, from: string, to: string, by: string, released
|
|
|
395
421
|
if (collision) throw new CliUserError(`이미 있는 칸: ${JSON.stringify(id).slice(1, -1)} — ${to} · 담당 ${JSON.stringify(collision.owner ?? '-').slice(1, -1)} · ${collision.title.replace(/\s+/g, ' ').slice(0, 40)}`);
|
|
396
422
|
const at = new Date().toISOString();
|
|
397
423
|
db.query('UPDATE assignments SET version = ?, updated_at = ?, updated_by = ? WHERE feature_id = ? AND version = ?').run(to, at, by, id, from);
|
|
424
|
+
if (options.clearDisposition) db.query('UPDATE assignments SET disposition = NULL WHERE feature_id = ? AND version = ?').run(id, to);
|
|
398
425
|
db.query('UPDATE evidence SET version = ? WHERE feature_id = ? AND version = ?').run(to, id, from);
|
|
399
426
|
record(db, to, { at, by, id, field: 'move', from, to, released, dev, ...(reason !== undefined ? { reason } : {}) });
|
|
400
427
|
return snapshot(db, to, released, dev);
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { releaseLedgerRoot } from '../instance/resolve.js';
|
|
5
|
+
import { debug } from '../debug/log.js';
|
|
6
|
+
import { listChecklist, setItem } from './checklist.js';
|
|
7
|
+
import { evidencePlan, landedButYellow, type MergedChecklistPr } from './landed-but-yellow.js';
|
|
8
|
+
import { assignedVersions, evidenceAdd } from './feature-store.js';
|
|
9
|
+
|
|
10
|
+
export interface MergedPrChecklistDeps {
|
|
11
|
+
readPr?: (number: number, cwd: string) => { state: string; baseRefName: string; title: string; body: string | null; mergedAt: string };
|
|
12
|
+
versions?: () => string[];
|
|
13
|
+
checklist?: typeof listChecklist;
|
|
14
|
+
addEvidence?: typeof evidenceAdd;
|
|
15
|
+
setStatus?: typeof setItem;
|
|
16
|
+
log?: typeof debug.log;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A confirmed main merge is the only entry. The existing checklist matcher owns PR-to-cell decisions. */
|
|
20
|
+
export function syncMergedPrChecklist(number: number, cwd: string, goalFile?: string, deps: MergedPrChecklistDeps = {}): void {
|
|
21
|
+
const log = deps.log ?? debug.log.bind(debug);
|
|
22
|
+
const pr = (deps.readPr ?? ((n, dir) => JSON.parse(execFileSync('gh', ['pr', 'view', String(n), '--json', 'state,baseRefName,title,body,mergedAt'], { cwd: dir, encoding: 'utf8', timeout: 30_000 })) as ReturnType<NonNullable<MergedPrChecklistDeps['readPr']>>))(number, cwd);
|
|
23
|
+
if (pr.state !== 'MERGED' || pr.baseRefName !== 'main') return;
|
|
24
|
+
const merged: MergedChecklistPr = { number, title: pr.title, body: pr.body, mergedAt: pr.mergedAt };
|
|
25
|
+
const versions = deps.versions?.() ?? (() => {
|
|
26
|
+
let recorded: string[] = [];
|
|
27
|
+
try { recorded = readdirSync(join(releaseLedgerRoot(), 'release')).filter((version) => /^\d+\.\d+\.\d+(?:-(?:rc|alpha|beta)\.\d+)?$/.test(version)); }
|
|
28
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
29
|
+
return [...new Set([...assignedVersions(), ...recorded])];
|
|
30
|
+
})();
|
|
31
|
+
let goal: string | undefined;
|
|
32
|
+
if (goalFile) {
|
|
33
|
+
try { goal = readFileSync(goalFile, 'utf8'); }
|
|
34
|
+
catch (error) { log('release.checklist', 'merged-pr-goal-unreadable', { pr: number, goalFile, error: String(error) }); }
|
|
35
|
+
}
|
|
36
|
+
// The audit matcher owns explicit-label and evidence decisions on both PR surfaces.
|
|
37
|
+
// The shared matcher only accepts a whole `칸: X` line. A designated label in the body (`칸: X — …` at a line head)
|
|
38
|
+
// or in the title is restated as such a line; prose mentions are not (the audit command's contract is unchanged).
|
|
39
|
+
const designated = [...goalCells(pr.body ?? ''), ...titleCells(pr.title)];
|
|
40
|
+
// Only the restated labels go to the matcher — raw body text (quotes, fences, indented code) never does.
|
|
41
|
+
const prForMatch = { ...merged, title: '', body: designated.map((id) => `칸: ${id}`).join('\n') };
|
|
42
|
+
const matches = [...new Set(versions)].flatMap((version) => {
|
|
43
|
+
const snapshot = (deps.checklist ?? listChecklist)(version);
|
|
44
|
+
// The audit excludes green/done; evidence still applies to already-green cells.
|
|
45
|
+
const candidates = snapshot.items.map((item) => ({ ...item, status: 'yellow' as const }));
|
|
46
|
+
return landedButYellow(candidates, [prForMatch])
|
|
47
|
+
.filter((row) => row.prs.some((p) => p.basis === 'cell-line'))
|
|
48
|
+
.map((row) => ({ version, snapshot, row }));
|
|
49
|
+
});
|
|
50
|
+
const counts = new Map<string, number>();
|
|
51
|
+
for (const { row } of matches) counts.set(row.id, (counts.get(row.id) ?? 0) + 1);
|
|
52
|
+
for (const [id, count] of counts) {
|
|
53
|
+
if (count > 1) log('release.checklist', 'merged-pr-cell-ambiguous', { pr: number, id, versions: matches.filter((match) => match.row.id === id).map((match) => match.version) });
|
|
54
|
+
}
|
|
55
|
+
for (const { version, snapshot, row } of matches) {
|
|
56
|
+
if (counts.get(row.id) !== 1) continue;
|
|
57
|
+
for (const { id, ref } of evidencePlan([row])) {
|
|
58
|
+
(deps.addEvidence ?? evidenceAdd)(id, version, ref, 'harness', snapshot.released, snapshot.dev);
|
|
59
|
+
log('release.checklist', 'merged-pr-evidence-added', { version, id, pr: number, ref });
|
|
60
|
+
}
|
|
61
|
+
if (goal && declaresCompletion(goal)) {
|
|
62
|
+
if (!goalCells(goal).has(row.id)) {
|
|
63
|
+
log('release.checklist', 'merged-pr-status-skipped', { version, id: row.id, pr: number, reason: 'goal-does-not-name-cell' });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const from = snapshot.items.find((item) => item.id === row.id)!.status;
|
|
67
|
+
if (from !== 'green' && from !== 'done') {
|
|
68
|
+
(deps.setStatus ?? setItem)(version, row.id, { status: 'green' }, 'harness');
|
|
69
|
+
log('release.checklist', 'merged-pr-status-green', { version, id: row.id, pr: number, from, to: 'green' });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const found = new Set(matches.map(({ row }) => row.id));
|
|
74
|
+
const missing = [...new Set(designated)].filter((id) => !found.has(id));
|
|
75
|
+
if (!designated.length) log('release.checklist', 'merged-pr-cell-not-found', { pr: number });
|
|
76
|
+
for (const id of missing) log('release.checklist', 'merged-pr-cell-not-found', { pr: number, id });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const LABEL_IDS = '([A-Za-z0-9_-]+(?:[ \\t]*,[ \\t]*[A-Za-z0-9_-]+)*)';
|
|
80
|
+
const LABEL_END = '(?=[ \\t]*$|[ \\t]+[—–·(-])';
|
|
81
|
+
const DESIGNATION = new RegExp(`^ {0,3}(?:#{1,6}[ \\t]+|[-*][ \\t]+)?칸[ \\t]*:[ \\t]*${LABEL_IDS}${LABEL_END}`);
|
|
82
|
+
|
|
83
|
+
/** Lines of a Markdown document outside fenced code (closed only by the same marker, at least as long), 4-space
|
|
84
|
+
* indented code and `>` quotes — the only lines that may designate a cell or declare completion. */
|
|
85
|
+
function proseLines(text: string): string[] {
|
|
86
|
+
const lines: string[] = [];
|
|
87
|
+
let fence: { char: string; length: number } | null = null;
|
|
88
|
+
for (const raw of text.split('\n')) {
|
|
89
|
+
const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
90
|
+
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line);
|
|
91
|
+
if (fence) {
|
|
92
|
+
if (marker && marker[1]![0] === fence.char && marker[1]!.length >= fence.length && line.trim() === marker[1]) fence = null;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (marker) { fence = { char: marker[1]![0]!, length: marker[1]!.length }; continue; }
|
|
96
|
+
if (/^( {4}|\t)/.test(line) || /^ {0,3}>/.test(line)) continue;
|
|
97
|
+
lines.push(line);
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Cell ids a document designates on a line head: `칸: X[, Y]`, optionally after a heading or list marker. */
|
|
103
|
+
export function goalCells(goal: string): Set<string> {
|
|
104
|
+
const ids = new Set<string>();
|
|
105
|
+
for (const line of proseLines(goal)) {
|
|
106
|
+
const label = DESIGNATION.exec(line);
|
|
107
|
+
if (!label) continue;
|
|
108
|
+
for (const id of label[1]!.split(',')) ids.add(id.trim());
|
|
109
|
+
}
|
|
110
|
+
return ids;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `이 칸 완료` counts only as a standalone prose line, not inside code or a quote. */
|
|
114
|
+
export function declaresCompletion(goal: string): boolean {
|
|
115
|
+
return proseLines(goal).some((line) => /^\s*이 칸 완료\s*$/.test(line));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A PR title is one authored line: `칸: X — …` may follow a short prefix such as `[TC]` or 「…」. */
|
|
119
|
+
export function titleCells(title: string): Set<string> {
|
|
120
|
+
const ids = new Set<string>();
|
|
121
|
+
for (const label of title.matchAll(new RegExp(`(?<![A-Za-z0-9가-힣])칸[ \\t]*:[ \\t]*${LABEL_IDS}${LABEL_END}`, 'g'))) {
|
|
122
|
+
for (const id of label[1]!.split(',')) ids.add(id.trim());
|
|
123
|
+
}
|
|
124
|
+
return ids;
|
|
125
|
+
}
|
|
@@ -5,18 +5,23 @@ import { CliUserError } from '../cli/cli-user-error.js';
|
|
|
5
5
|
import { getElanousConfigDirOverride } from '../elanous-config-dir.js';
|
|
6
6
|
import { effectiveInstanceRoot } from '../instance/resolve.js';
|
|
7
7
|
import { userConfigPath } from '../user-config.js';
|
|
8
|
-
import { addItem, listChecklist, ownerMatches, parseOwner, setItem, type ChecklistItem } from './checklist.js';
|
|
8
|
+
import { addItem, listChecklist, ownerMatches, parseOwner, setItem, validateCeoLoad, type ChecklistItem } from './checklist.js';
|
|
9
9
|
import { move, releasedVersion } from './feature-store.js';
|
|
10
10
|
import { listSchedules, type ReleaseSchedule } from './release-schedule.js';
|
|
11
11
|
|
|
12
12
|
export type PlacementPriority = 'P0' | 'P1' | 'P2';
|
|
13
|
-
export interface PlacementCell { id: string; title: string; owner: string; priority: PlacementPriority; predecessors: string[]; deadlineVersion?: string }
|
|
13
|
+
export interface PlacementCell { id: string; title: string; owner: string; priority: PlacementPriority; predecessors: string[]; deadlineVersion?: string; ceoMinutes?: number; ceoDate?: string }
|
|
14
14
|
export interface PlacementDecision { id: string; from: string | null; version: string; reason: string; displaced: Array<{ id: string; from: string; to: string; reason: string }> }
|
|
15
|
+
export interface RebalanceResult {
|
|
16
|
+
decisions: PlacementDecision[];
|
|
17
|
+
blocked: Array<{ id: string; from: string; to: string; reason: string }>;
|
|
18
|
+
}
|
|
15
19
|
export interface PlacementDeps {
|
|
16
20
|
now?: Date;
|
|
17
21
|
schedules?: ReleaseSchedule[];
|
|
18
22
|
merged24h?: number;
|
|
19
23
|
seatCap?: Record<string, number>;
|
|
24
|
+
ceoDailyCap?: number;
|
|
20
25
|
checklist?: typeof listChecklist;
|
|
21
26
|
released?: string;
|
|
22
27
|
dryRun?: boolean;
|
|
@@ -47,11 +52,38 @@ export function mergedPrsLast24h(now = new Date()): number {
|
|
|
47
52
|
return rows.filter((row) => Date.parse(row.mergedAt) >= Date.parse(since) && Date.parse(row.mergedAt) <= now.getTime()).length;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
function placementConfig(): { seatCap?: Record<string, number>; ceoDailyCap?: number } {
|
|
51
56
|
const path = getElanousConfigDirOverride() ? join(effectiveInstanceRoot(), 'config.json') : userConfigPath();
|
|
52
57
|
if (!existsSync(path)) return {};
|
|
53
|
-
const raw = JSON.parse(readFileSync(path, 'utf8')) as { release?: { placement?: { seatCap?: Record<string, number
|
|
54
|
-
return raw.release?.placement
|
|
58
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as { release?: { placement?: { seatCap?: Record<string, number>; ceoDailyCap?: number } } };
|
|
59
|
+
return raw.release?.placement ?? {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function placementSeatCap(): Record<string, number> { return placementConfig().seatCap ?? {}; }
|
|
63
|
+
function validCeoCap(cap: number): number {
|
|
64
|
+
if (!Number.isSafeInteger(cap) || cap < 0) throw new CliUserError('잘못된 대표 하루 상한: release.placement.ceoDailyCap');
|
|
65
|
+
return cap;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function placementCeoDailyCap(): number { return validCeoCap(placementConfig().ceoDailyCap ?? 30); }
|
|
69
|
+
|
|
70
|
+
/** Explicit day wins; otherwise a cell uses the target release's landing day in KST. */
|
|
71
|
+
function ceoDay(item: { ceoDate?: string }, row: ReleaseSchedule): string {
|
|
72
|
+
return item.ceoDate ?? new Date(Date.parse(row.landBy!) + 9 * 3_600_000).toISOString().slice(0, 10);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type CeoWork = Pick<ChecklistItem, 'id' | 'ceoMinutes' | 'ceoDate'>;
|
|
76
|
+
function ceoOverload(item: CeoWork, row: ReleaseSchedule, snapshots: Map<string, { items: CeoWork[] }>, schedules: ReleaseSchedule[], cap: number): string | null {
|
|
77
|
+
if (!item.ceoMinutes) return null;
|
|
78
|
+
const day = ceoDay(item, row);
|
|
79
|
+
const peers = [...snapshots].flatMap(([version, snapshot]) => {
|
|
80
|
+
const schedule = schedules.find((candidate) => candidate.version === version);
|
|
81
|
+
return snapshot.items.filter((other) => other.id !== item.id && other.ceoMinutes
|
|
82
|
+
&& (other.ceoDate === day || (!other.ceoDate && schedule?.landBy && ceoDay(other, schedule) === day)));
|
|
83
|
+
});
|
|
84
|
+
const total = item.ceoMinutes + peers.reduce((sum, other) => sum + other.ceoMinutes!, 0);
|
|
85
|
+
if (total <= cap) return null;
|
|
86
|
+
return `대표 손 과부하 ${day} KST: ${[...peers.map((peer) => `${peer.id} ${peer.ceoMinutes}분`), `${item.id} ${item.ceoMinutes}분`].join(' + ')} = ${total}분 > 하루 상한 ${cap}분 — 늦추기(다음 날짜/판) · 자리 대행(대표 분량 축소) · 묶기(촬영·승인 합산 분량 축소)를 제안`;
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
function dependencyViolation(
|
|
@@ -87,6 +119,7 @@ function available(row: ReleaseSchedule, now: number, backlog: readonly string[]
|
|
|
87
119
|
export function placeCell(input: PlacementCell, deps: PlacementDeps = {}): PlacementDecision {
|
|
88
120
|
if (!input.id.trim() || !input.title.trim()) throw new CliUserError('칸 id 와 제목이 필요하다');
|
|
89
121
|
if (!Array.isArray(input.predecessors) || input.predecessors.some((id) => typeof id !== 'string' || !id.trim() || id === input.id)) throw new CliUserError('선행 칸 id 가 잘못됐다');
|
|
122
|
+
validateCeoLoad(input);
|
|
90
123
|
const seat = parseOwner(input.owner).seat;
|
|
91
124
|
if (!['P0', 'P1', 'P2'].includes(input.priority)) throw new CliUserError(`잘못된 우선순위: ${input.priority}`);
|
|
92
125
|
const now = (deps.now ?? new Date()).getTime();
|
|
@@ -109,6 +142,8 @@ export function placeCell(input: PlacementCell, deps: PlacementDeps = {}): Place
|
|
|
109
142
|
if (from && released && versionOrder(from, released) <= 0) throw new CliUserError(`이미 발행된 판의 칸은 배치하지 않는다: ${input.id}`);
|
|
110
143
|
if (existing?.status === 'done') throw new CliUserError(`끝난 칸은 배치하지 않는다: ${input.id}`);
|
|
111
144
|
if (existing && (existing.title !== input.title || existing.owner !== input.owner)) throw new CliUserError(`기존 칸 제목·담당 불일치: ${input.id}`);
|
|
145
|
+
const load = { id: input.id, ceoMinutes: input.ceoMinutes ?? existing?.ceoMinutes, ceoDate: input.ceoDate ?? existing?.ceoDate };
|
|
146
|
+
const ceoCap = validCeoCap(deps.ceoDailyCap ?? placementCeoDailyCap());
|
|
112
147
|
const predecessors = input.predecessors.map((id) => {
|
|
113
148
|
const version = locations.get(id);
|
|
114
149
|
if (!version) throw new CliUserError(`없는 선행 칸: ${id}`);
|
|
@@ -133,46 +168,63 @@ export function placeCell(input: PlacementCell, deps: PlacementDeps = {}): Place
|
|
|
133
168
|
const capacity = (row: ReleaseSchedule) => Math.floor(rate * (Date.parse(row.landBy!) - now) / 86_400_000 * 0.7);
|
|
134
169
|
const displaced: PlacementDecision['displaced'] = [];
|
|
135
170
|
let chosen: ReleaseSchedule | undefined;
|
|
136
|
-
|
|
171
|
+
let loadIssue: string | null = null;
|
|
172
|
+
const feasible = candidates.filter((row) => !dependencyViolation(snapshots, locations, new Map([[input.id, row.version]]), input));
|
|
173
|
+
for (const row of feasible) {
|
|
137
174
|
const placement = new Map([[input.id, row.version]]);
|
|
138
|
-
if (dependencyViolation(snapshots, locations, placement, input)) continue;
|
|
139
175
|
const items = occupied(row.version);
|
|
140
176
|
const seatItems = items.filter((item) => ownerMatches(item.owner, seat));
|
|
141
|
-
|
|
142
|
-
if (
|
|
177
|
+
const directIssue = ceoOverload(load, row, snapshots, schedules, ceoCap);
|
|
178
|
+
if (items.length < capacity(row) && seatItems.length < seatLimit) {
|
|
179
|
+
if (directIssue) { loadIssue ??= directIssue; continue; }
|
|
180
|
+
chosen = row;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (input.priority !== 'P1' || from === row.version) { loadIssue ??= directIssue; continue; }
|
|
143
184
|
const next = open.find((later) => versionOrder(later.version, row.version) > 0);
|
|
144
|
-
if (!next || items.length - 1 >= capacity(row)) continue;
|
|
185
|
+
if (!next || items.length - 1 >= capacity(row)) { loadIssue ??= directIssue; continue; }
|
|
145
186
|
const p2 = [...items].reverse().find((item) => {
|
|
146
187
|
if (item.priority !== 'P2' || dependencyViolation(snapshots, locations, new Map([...placement, [item.id, next.version]]), input)) return false;
|
|
147
188
|
if (item.deadlineVersion && versionOrder(next.version, item.deadlineVersion) > 0) return false;
|
|
148
189
|
if (items.length < capacity(row) && !(seatItems.length >= seatLimit && ownerMatches(item.owner, seat))) return false;
|
|
149
190
|
if (!item.owner) return false;
|
|
150
191
|
const shiftedSeat = parseOwner(item.owner).seat;
|
|
151
|
-
|
|
192
|
+
if (occupied(next.version).length >= capacity(next) || occupied(next.version).filter((other) => ownerMatches(other.owner, shiftedSeat)).length >= (caps[shiftedSeat] ?? Infinity)) return false;
|
|
193
|
+
if (!(seatItems.length < seatLimit || (ownerMatches(item.owner, seat) && seatItems.length - 1 < seatLimit))) return false;
|
|
194
|
+
|
|
195
|
+
// Evaluate both changed cells against the same post-placement state, before either ledger write.
|
|
196
|
+
const proposed = new Map<string, { items: CeoWork[] }>([...snapshots].map(([version, snapshot]) => [version, { items: snapshot.items.filter((cell) => cell.id !== input.id && cell.id !== item.id) }]));
|
|
197
|
+
proposed.get(row.version)!.items.push(load);
|
|
198
|
+
proposed.get(next.version)!.items.push(item);
|
|
199
|
+
const issue = ceoOverload(load, row, proposed, schedules, ceoCap)
|
|
200
|
+
?? ceoOverload(item, next, proposed, schedules, ceoCap);
|
|
201
|
+
if (issue) { loadIssue ??= issue; return false; }
|
|
202
|
+
return true;
|
|
152
203
|
});
|
|
153
|
-
if (p2
|
|
204
|
+
if (p2) {
|
|
154
205
|
displaced.push({ id: p2.id, from: row.version, to: next.version, reason: `P1 ${input.id} 마감 판 ${row.version} 용량 확보를 위해 P2 이월` });
|
|
155
206
|
chosen = row;
|
|
156
207
|
break;
|
|
157
208
|
}
|
|
209
|
+
loadIssue ??= directIssue;
|
|
158
210
|
}
|
|
159
|
-
if (!chosen) throw new CliUserError(`${input.id} 배치할 판이 없다 — 용량·선행·마감·동결을 확인하라`);
|
|
211
|
+
if (!chosen) throw new CliUserError(loadIssue ?? `${input.id} 배치할 판이 없다 — 용량·선행·마감·동결을 확인하라`);
|
|
160
212
|
const reason = `${input.priority} ${input.priority === 'P0' ? '사고·회귀·발행 막음: 다음 판' : input.priority === 'P1' ? `지시·행사 마감 판 ${deadlineVersion}` : '착지 마감 내 가장 이른 여유 판'} · PR/24h ${rate} · 용량 ${capacity(chosen)} · 자리 ${seatLimit}`;
|
|
161
213
|
const decision = { id: input.id, from, version: chosen.version, reason, displaced };
|
|
162
214
|
if (!deps.dryRun) {
|
|
163
215
|
const by = deps.by ?? 'OP';
|
|
164
216
|
for (const shifted of displaced) move(shifted.id, shifted.from, shifted.to, by, undefined, undefined, shifted.reason);
|
|
165
217
|
if (from && from !== chosen.version) move(input.id, from, chosen.version, by, undefined, undefined, reason);
|
|
166
|
-
if (from && (existing?.priority !== input.priority || JSON.stringify(existing.predecessors ?? []) !== JSON.stringify(input.predecessors) || (input.deadlineVersion !== undefined && existing.deadlineVersion !== input.deadlineVersion)))
|
|
167
|
-
setItem(chosen.version, input.id, { priority: input.priority, predecessors: input.predecessors, ...(input.deadlineVersion ? { deadlineVersion: input.deadlineVersion } : {}) }, by);
|
|
168
|
-
if (!from) addItem(chosen.version, { id: input.id, title: input.title, owner: input.owner, priority: input.priority, predecessors: input.predecessors, deadlineVersion });
|
|
218
|
+
if (from && (existing?.priority !== input.priority || JSON.stringify(existing.predecessors ?? []) !== JSON.stringify(input.predecessors) || (input.deadlineVersion !== undefined && existing.deadlineVersion !== input.deadlineVersion) || (input.ceoMinutes !== undefined && existing.ceoMinutes !== input.ceoMinutes) || (input.ceoDate !== undefined && existing.ceoDate !== input.ceoDate)))
|
|
219
|
+
setItem(chosen.version, input.id, { priority: input.priority, predecessors: input.predecessors, ...(input.deadlineVersion ? { deadlineVersion: input.deadlineVersion } : {}), ...(input.ceoMinutes !== undefined ? { ceoMinutes: input.ceoMinutes } : {}), ...(input.ceoDate !== undefined ? { ceoDate: input.ceoDate } : {}) }, by);
|
|
220
|
+
if (!from) addItem(chosen.version, { id: input.id, title: input.title, owner: input.owner, priority: input.priority, predecessors: input.predecessors, deadlineVersion, ...(load.ceoMinutes !== undefined ? { ceoMinutes: load.ceoMinutes } : {}), ...(load.ceoDate !== undefined ? { ceoDate: load.ceoDate } : {}) });
|
|
169
221
|
}
|
|
170
222
|
return decision;
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
function moveConstraint(
|
|
174
226
|
item: ChecklistItem, to: ReleaseSchedule, backlog: readonly string[],
|
|
175
|
-
snapshots: Map<string, ChecklistItem[]>, now: number, rate: number, caps: Record<string, number>,
|
|
227
|
+
snapshots: Map<string, ChecklistItem[]>, now: number, rate: number, caps: Record<string, number>, rows: ReleaseSchedule[], ceoCap: number,
|
|
176
228
|
): string | null {
|
|
177
229
|
if (!to.landBy || !available(to, now, backlog)) return '착지 마감이 지났거나 백로그 판이라 이동 불가';
|
|
178
230
|
if (item.deadlineVersion && versionOrder(to.version, item.deadlineVersion) > 0) return '마감 판 뒤로 이월 불가';
|
|
@@ -181,6 +233,9 @@ function moveConstraint(
|
|
|
181
233
|
const destination = snapshots.get(to.version) ?? [];
|
|
182
234
|
if (destination.filter((cell) => cell.status !== 'done').length >= Math.floor(rate * (Date.parse(to.landBy) - now) / 86_400_000 * 0.7)) return '다음 판 PR 용량 초과';
|
|
183
235
|
if (destination.filter((cell) => cell.status !== 'done' && ownerMatches(cell.owner, seat)).length >= (caps[seat] ?? Infinity)) return '다음 판 자리 용량 초과';
|
|
236
|
+
const loadIssue = ceoOverload(item, to, new Map([...snapshots].map(([version, items]) => [version, { items }])),
|
|
237
|
+
rows, ceoCap);
|
|
238
|
+
if (loadIssue) return loadIssue;
|
|
184
239
|
const locations = new Map<string, string>();
|
|
185
240
|
for (const [version, items] of snapshots) for (const cell of items) {
|
|
186
241
|
if (locations.has(cell.id)) return `여러 판의 같은 칸: ${cell.id}`;
|
|
@@ -198,30 +253,35 @@ function moveConstraint(
|
|
|
198
253
|
}
|
|
199
254
|
|
|
200
255
|
/** Only unstarted cells inside the two-hour pre-deadline window move; a dry run never writes. */
|
|
201
|
-
export function rebalance(version: string, deps: PlacementDeps = {}):
|
|
256
|
+
export function rebalance(version: string, deps: PlacementDeps = {}): RebalanceResult {
|
|
202
257
|
const rows = [...(deps.schedules ?? listSchedules())].sort((a, b) => versionOrder(a.version, b.version));
|
|
203
258
|
const current = rows.find((row) => row.version === version);
|
|
204
259
|
if (!current?.landBy) throw new CliUserError(`착지 마감이 없는 판: ${version}`);
|
|
205
260
|
const now = (deps.now ?? new Date()).getTime();
|
|
206
|
-
if (Date.parse(current.landBy) - now > 7_200_000 || Date.parse(current.landBy) <= now) return [];
|
|
261
|
+
if (Date.parse(current.landBy) - now > 7_200_000 || Date.parse(current.landBy) <= now) return { decisions: [], blocked: [] };
|
|
207
262
|
const backlog = deps.backlog ?? BACKLOG_VERSIONS;
|
|
208
263
|
const next = rows.find((row) => versionOrder(row.version, version) > 0 && !backlog.includes(row.version));
|
|
209
264
|
const snapshots = new Map(rows.map((row) => [row.version, [...(deps.checklist ?? listChecklist)(row.version).items]]));
|
|
210
265
|
const unstarted = snapshots.get(version)!.filter((item) => item.status === 'yellow' && !item.evidence);
|
|
211
266
|
if (unstarted.length && !next) throw new CliUserError(`${version} 다음 판이 없다`);
|
|
212
|
-
if (!unstarted.length) return [];
|
|
267
|
+
if (!unstarted.length) return { decisions: [], blocked: [] };
|
|
213
268
|
const rate = deps.merged24h ?? mergedPrsLast24h(new Date(now));
|
|
214
269
|
const caps = deps.seatCap ?? placementSeatCap();
|
|
215
|
-
const
|
|
270
|
+
const ceoCap = validCeoCap(deps.ceoDailyCap ?? placementCeoDailyCap());
|
|
271
|
+
const result: RebalanceResult = { decisions: [], blocked: [] };
|
|
216
272
|
for (const item of unstarted) {
|
|
217
|
-
|
|
273
|
+
const violation = moveConstraint(item, next!, backlog, snapshots, now, rate, caps, rows, ceoCap);
|
|
274
|
+
if (violation) {
|
|
275
|
+
result.blocked.push({ id: item.id, from: version, to: next!.version, reason: violation });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
218
278
|
const reason = `${version} 착지 마감 2시간 전 미시작 칸 이월`;
|
|
219
|
-
decisions.push({ id: item.id, from: version, version: next!.version, reason, displaced: [] });
|
|
279
|
+
result.decisions.push({ id: item.id, from: version, version: next!.version, reason, displaced: [] });
|
|
220
280
|
snapshots.set(version, snapshots.get(version)!.filter((cell) => cell.id !== item.id));
|
|
221
281
|
snapshots.get(next!.version)!.push(item);
|
|
222
282
|
}
|
|
223
|
-
if (!deps.dryRun) for (const decision of decisions) move(decision.id, version, decision.version, deps.by ?? 'OP', undefined, undefined, decision.reason);
|
|
224
|
-
return
|
|
283
|
+
if (!deps.dryRun) for (const decision of result.decisions) move(decision.id, version, decision.version, deps.by ?? 'OP', undefined, undefined, decision.reason);
|
|
284
|
+
return result;
|
|
225
285
|
}
|
|
226
286
|
|
|
227
287
|
/** A seat may defer its own cell one release, never pull another owner's cell forward. */
|
|
@@ -236,7 +296,7 @@ export function seatMove(id: string, from: string, to: string, by: string, reaso
|
|
|
236
296
|
if (!ownerMatches(item.owner, parseOwner(by).seat) || next?.version !== to) throw new CliUserError('남의 칸 당기기 거부 — COO 에 요청');
|
|
237
297
|
const now = (deps.now ?? new Date()).getTime();
|
|
238
298
|
const rate = deps.merged24h ?? mergedPrsLast24h(new Date(now));
|
|
239
|
-
const violation = moveConstraint(item, next, backlog, snapshots, now, rate, deps.seatCap ?? placementSeatCap());
|
|
299
|
+
const violation = moveConstraint(item, next, backlog, snapshots, now, rate, deps.seatCap ?? placementSeatCap(), rows, validCeoCap(deps.ceoDailyCap ?? placementCeoDailyCap()));
|
|
240
300
|
if (violation) throw new CliUserError(`${id}: ${violation}`);
|
|
241
301
|
if (!deps.dryRun) move(id, from, to, by, undefined, undefined, reason);
|
|
242
302
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const DEFAULT_MAX_REQUEST_AGE_HOURS = 48;
|
|
2
|
+
|
|
3
|
+
export type SeatRequestEligibility =
|
|
4
|
+
| { eligible: true }
|
|
5
|
+
| { eligible: false; reason: 'empty-body' | 'too-old' | 'slot-closed' };
|
|
6
|
+
|
|
7
|
+
/** Unknown/unreadable slot status does not establish that a slot has closed. */
|
|
8
|
+
export function seatRequestEligibility(
|
|
9
|
+
request: { text: string; queuedAt: string },
|
|
10
|
+
options: { now: Date; maxRequestAgeHours?: number; slotStatus?: string | null },
|
|
11
|
+
): SeatRequestEligibility {
|
|
12
|
+
if (!request.text.trim()) return { eligible: false, reason: 'empty-body' };
|
|
13
|
+
const maxAgeHours = options.maxRequestAgeHours ?? DEFAULT_MAX_REQUEST_AGE_HOURS;
|
|
14
|
+
if (!Number.isFinite(maxAgeHours) || maxAgeHours < 0) throw new Error('invalid seat request max age hours');
|
|
15
|
+
const age = options.now.getTime() - Date.parse(request.queuedAt);
|
|
16
|
+
if (!Number.isFinite(age) || age > maxAgeHours * 60 * 60_000) return { eligible: false, reason: 'too-old' };
|
|
17
|
+
if (options.slotStatus === 'green' || options.slotStatus === 'done') return { eligible: false, reason: 'slot-closed' };
|
|
18
|
+
return { eligible: true };
|
|
19
|
+
}
|