code-coordinator 0.5.46__py3-none-any.whl
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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/dao.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Storage-agnostic data-access layer for the coordinator board (#584/#589).
|
|
2
|
+
|
|
3
|
+
``CoordStore`` is the seam that lets the ``coord serve`` daemon front the board
|
|
4
|
+
without callers knowing the storage engine — and makes a future Postgres backend
|
|
5
|
+
(#282) a contained swap rather than a rewrite. This module implements the
|
|
6
|
+
**read** side fully (the portable control center's read path) and declares the
|
|
7
|
+
**write** side for #590.
|
|
8
|
+
|
|
9
|
+
``SqliteStore`` is the concrete SQLite implementation. It owns its *own*
|
|
10
|
+
read-only connection (NOT ``coord.db``'s read/write singleton): it opens the DB
|
|
11
|
+
with ``mode=ro`` + ``PRAGMA query_only`` and never runs schema/migration DDL, so
|
|
12
|
+
it is safe to point at a live ``coord.db`` that the coordinator process is
|
|
13
|
+
writing in WAL mode.
|
|
14
|
+
|
|
15
|
+
All SQLite idioms (``sqlite3.Row``, JSON-encoded TEXT columns, the ``mode=ro``
|
|
16
|
+
URI) are encapsulated here: read methods return plain Python dicts with JSON
|
|
17
|
+
columns decoded to native lists/objects, so neither the wire format nor a future
|
|
18
|
+
non-SQLite backend inherits any SQLite-only idiom.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sqlite3
|
|
26
|
+
import time
|
|
27
|
+
from contextlib import closing
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Protocol, runtime_checkable
|
|
30
|
+
|
|
31
|
+
from coord.db import DB_PATH
|
|
32
|
+
|
|
33
|
+
# Bump when the /board payload shape changes incompatibly. Clients may branch
|
|
34
|
+
# on this; today everything is additive.
|
|
35
|
+
SCHEMA_VERSION = 1
|
|
36
|
+
|
|
37
|
+
# #762: terminal assignment statuses. Anything NOT in this set (running /
|
|
38
|
+
# pending) is "in-flight" and always kept on the board projection.
|
|
39
|
+
TERMINAL_STATUSES = frozenset({"done", "merged", "failed", "cancelled", "advisory"})
|
|
40
|
+
|
|
41
|
+
# #762: how many days of *terminal* assignment history the /board projection
|
|
42
|
+
# carries. The board grew unbounded (1209 assignments / 4.37 MB) and overran
|
|
43
|
+
# the TUI's fetch timeout, blanking the whole board. Cap the wire to active +
|
|
44
|
+
# pipeline-referenced + this many days of terminal rows. 0 disables the cap
|
|
45
|
+
# (serve everything — the pre-#762 behaviour).
|
|
46
|
+
_DEFAULT_BOARD_RETENTION_DAYS = 14
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _board_retention_days() -> int:
|
|
50
|
+
try:
|
|
51
|
+
return int(os.environ.get("COORD_BOARD_RETENTION_DAYS", _DEFAULT_BOARD_RETENTION_DAYS))
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return _DEFAULT_BOARD_RETENTION_DAYS
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# #1037: recency window (seconds) behind the /board `audit_recent_count`
|
|
57
|
+
# summary field — see SqliteStore._audit_recent_count.
|
|
58
|
+
_AUDIT_RECENT_WINDOW_SECONDS = 900
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _board_retention_cutoff(now: float | None = None) -> float | None:
|
|
62
|
+
"""Unix-timestamp floor for terminal rows on the board, or None when the
|
|
63
|
+
cap is disabled (``COORD_BOARD_RETENTION_DAYS=0``)."""
|
|
64
|
+
days = _board_retention_days()
|
|
65
|
+
if days <= 0:
|
|
66
|
+
return None
|
|
67
|
+
return (time.time() if now is None else now) - days * 86400.0
|
|
68
|
+
|
|
69
|
+
# JSON-encoded columns per table — decoded to native objects on read so no
|
|
70
|
+
# SQLite idiom (JSON-in-TEXT) leaks past the DAO. Columns added by later ALTER
|
|
71
|
+
# migrations are picked up automatically via ``SELECT *``; only JSON ones need
|
|
72
|
+
# listing here.
|
|
73
|
+
_JSON_COLUMNS: dict[str, set[str]] = {
|
|
74
|
+
"assignments": {
|
|
75
|
+
"files_allowed",
|
|
76
|
+
"files_forbidden",
|
|
77
|
+
"required_gates",
|
|
78
|
+
"plan",
|
|
79
|
+
"smoke_tests",
|
|
80
|
+
"test_plan",
|
|
81
|
+
# NOTE: review_findings is deliberately NOT decoded — the coord-tui
|
|
82
|
+
# client consumes it as a raw JSON string (Option<String>), so it must
|
|
83
|
+
# stay a string on the wire.
|
|
84
|
+
},
|
|
85
|
+
"proposals": {"files_likely", "required_gates"},
|
|
86
|
+
"merge_queue": {"required_gates"},
|
|
87
|
+
"issues": {"labels"},
|
|
88
|
+
"machines": {"capabilities", "repos"},
|
|
89
|
+
# #1753: the drive queue's pre-req list — ["repo#N", ...] on the wire, a
|
|
90
|
+
# JSON string in SQLite.
|
|
91
|
+
"drive_queue": {"after_json"},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Columns omitted from the board projection. ``assignments.briefing`` is ~8 MB
|
|
95
|
+
# of an ~12 MB live payload and is NOT part of the board view (the TUI's board
|
|
96
|
+
# query never selects it; the Python mapper defaults it to ""), so dropping it
|
|
97
|
+
# keeps refreshes fast over Tailscale. A per-assignment endpoint can serve full
|
|
98
|
+
# briefings later if a detail view needs them.
|
|
99
|
+
_DROP_COLUMNS: dict[str, set[str]] = {
|
|
100
|
+
"assignments": {"briefing"},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@runtime_checkable
|
|
105
|
+
class CoordStore(Protocol):
|
|
106
|
+
"""Read/write interface over coordinator board state.
|
|
107
|
+
|
|
108
|
+
The read methods are implemented today. The write methods are declared so
|
|
109
|
+
#590 (route writes through the daemon) lands as an implementation, not an
|
|
110
|
+
interface change.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
# ── reads (implemented now) ──────────────────────────────────────────────
|
|
114
|
+
def list_assignments(self) -> list[dict]: ...
|
|
115
|
+
def list_machines(self) -> list[dict]: ...
|
|
116
|
+
def list_merge_queue(self) -> list[dict]: ...
|
|
117
|
+
def list_drive_escalations(self) -> list[dict]: ...
|
|
118
|
+
def list_drive_queue(self) -> list[dict]: ...
|
|
119
|
+
def list_proposals(self) -> list[dict]: ...
|
|
120
|
+
def list_issues(self) -> list[dict]: ...
|
|
121
|
+
def list_plans(self) -> dict[str, Any]: ...
|
|
122
|
+
def list_notifications(self) -> list[dict]: ...
|
|
123
|
+
def board_meta(self) -> dict[str, str]: ...
|
|
124
|
+
def round_number(self) -> int: ...
|
|
125
|
+
def board_projection(self) -> dict: ...
|
|
126
|
+
|
|
127
|
+
# ── point reads (#1336/#1337: detail endpoints) ──────────────────────────
|
|
128
|
+
def get_assignment(self, assignment_id: str) -> dict | None: ...
|
|
129
|
+
def get_issue(self, repo_name: str, number: int) -> dict | None: ...
|
|
130
|
+
|
|
131
|
+
# ── writes (declared; implemented in #590) ───────────────────────────────
|
|
132
|
+
def record_result(self, record: Any) -> Any: ...
|
|
133
|
+
def record_completion(self, record: Any) -> Any: ...
|
|
134
|
+
def record_dispatched(self, assignment: Any) -> None: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def compute_board_keep_ids(
|
|
138
|
+
assignment_index: list[dict],
|
|
139
|
+
merge_queue_ids: set[str],
|
|
140
|
+
open_issue_keys: set[tuple[str, int]],
|
|
141
|
+
cutoff: float | None,
|
|
142
|
+
) -> set[str]:
|
|
143
|
+
"""Return the set of ``assignment_id``s the /board projection must carry.
|
|
144
|
+
|
|
145
|
+
Pure function (no DB) so the read path (:meth:`SqliteStore.board_projection`)
|
|
146
|
+
and the #762 archival sweep apply *identical* rules.
|
|
147
|
+
|
|
148
|
+
``assignment_index`` rows need only: ``assignment_id``, ``repo_name``,
|
|
149
|
+
``issue_number``, ``status``, ``dispatched_at``, ``finished_at``,
|
|
150
|
+
``review_of_assignment_id``.
|
|
151
|
+
|
|
152
|
+
A row is kept when it is **active** (status not terminal), **recent** (its
|
|
153
|
+
finish — or dispatch when never finished — is within the retention window),
|
|
154
|
+
**queued for merge**, or the **latest assignment of a still-open issue**.
|
|
155
|
+
The kept set is then closed over ``review_of_assignment_id`` in both
|
|
156
|
+
directions, so an in-flight item never loses its work↔review pairing.
|
|
157
|
+
|
|
158
|
+
``cutoff=None`` (cap disabled) keeps everything.
|
|
159
|
+
"""
|
|
160
|
+
by_id: dict[str, dict] = {
|
|
161
|
+
r["assignment_id"]: r for r in assignment_index if r.get("assignment_id")
|
|
162
|
+
}
|
|
163
|
+
if cutoff is None:
|
|
164
|
+
return set(by_id)
|
|
165
|
+
|
|
166
|
+
keep: set[str] = set()
|
|
167
|
+
latest_open: dict[tuple, tuple[float, str]] = {}
|
|
168
|
+
for r in assignment_index:
|
|
169
|
+
aid = r.get("assignment_id")
|
|
170
|
+
if not aid:
|
|
171
|
+
continue
|
|
172
|
+
status = (r.get("status") or "").lower()
|
|
173
|
+
if status not in TERMINAL_STATUSES:
|
|
174
|
+
keep.add(aid)
|
|
175
|
+
else:
|
|
176
|
+
ts = r.get("finished_at")
|
|
177
|
+
if ts is None:
|
|
178
|
+
ts = r.get("dispatched_at")
|
|
179
|
+
# Conservative: only drop a terminal row we can POSITIVELY date as
|
|
180
|
+
# old. An undatable terminal row (no finish/dispatch timestamp) is
|
|
181
|
+
# kept rather than risk dropping something we can't age.
|
|
182
|
+
if ts is None or ts >= cutoff:
|
|
183
|
+
keep.add(aid)
|
|
184
|
+
elif aid in merge_queue_ids:
|
|
185
|
+
keep.add(aid)
|
|
186
|
+
# Track the most-recently-dispatched assignment per open issue so an
|
|
187
|
+
# open pipeline card never loses its latest state to the age cap.
|
|
188
|
+
key = (r.get("repo_name"), r.get("issue_number"))
|
|
189
|
+
if key in open_issue_keys:
|
|
190
|
+
disp = r.get("dispatched_at") or 0.0
|
|
191
|
+
cur = latest_open.get(key)
|
|
192
|
+
if cur is None or disp >= cur[0]:
|
|
193
|
+
latest_open[key] = (disp, aid)
|
|
194
|
+
for _, aid in latest_open.values():
|
|
195
|
+
keep.add(aid)
|
|
196
|
+
|
|
197
|
+
# Closure over review links (work → its reviews, review → its work).
|
|
198
|
+
reviews_of: dict[str, list[str]] = {}
|
|
199
|
+
for aid, r in by_id.items():
|
|
200
|
+
tgt = r.get("review_of_assignment_id")
|
|
201
|
+
if tgt:
|
|
202
|
+
reviews_of.setdefault(tgt, []).append(aid)
|
|
203
|
+
frontier = list(keep)
|
|
204
|
+
while frontier:
|
|
205
|
+
aid = frontier.pop()
|
|
206
|
+
tgt = by_id.get(aid, {}).get("review_of_assignment_id")
|
|
207
|
+
if tgt and tgt in by_id and tgt not in keep:
|
|
208
|
+
keep.add(tgt)
|
|
209
|
+
frontier.append(tgt)
|
|
210
|
+
for rev in reviews_of.get(aid, ()):
|
|
211
|
+
if rev not in keep:
|
|
212
|
+
keep.add(rev)
|
|
213
|
+
frontier.append(rev)
|
|
214
|
+
return keep
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_KEEP_INDEX_COLUMNS = (
|
|
218
|
+
"assignment_id, repo_name, issue_number, status, dispatched_at, "
|
|
219
|
+
"finished_at, review_of_assignment_id"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _decode_row(table: str, row: sqlite3.Row, *, full: bool = False) -> dict:
|
|
224
|
+
"""sqlite3.Row → plain dict with that table's JSON columns decoded.
|
|
225
|
+
|
|
226
|
+
``full=True`` keeps the :data:`_DROP_COLUMNS` fields (e.g.
|
|
227
|
+
``assignments.briefing``) — used by the single-resource *detail* reads
|
|
228
|
+
(#1336/#1337), which serve the complete row; the collection projection
|
|
229
|
+
stays slim.
|
|
230
|
+
"""
|
|
231
|
+
d = dict(row)
|
|
232
|
+
if not full:
|
|
233
|
+
for col in _DROP_COLUMNS.get(table, ()):
|
|
234
|
+
d.pop(col, None)
|
|
235
|
+
for col in _JSON_COLUMNS.get(table, ()):
|
|
236
|
+
val = d.get(col)
|
|
237
|
+
if isinstance(val, (str, bytes, bytearray)):
|
|
238
|
+
try:
|
|
239
|
+
d[col] = json.loads(val) if val else None
|
|
240
|
+
except (json.JSONDecodeError, TypeError):
|
|
241
|
+
d[col] = None
|
|
242
|
+
return d
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class SqliteStore:
|
|
246
|
+
"""Read-only SQLite-backed :class:`CoordStore`.
|
|
247
|
+
|
|
248
|
+
Opens a fresh ``mode=ro`` connection per call (cheap for SQLite, thread-safe
|
|
249
|
+
under the daemon's request handling, and never migrates the DB).
|
|
250
|
+
``board_projection`` opens a single connection so the whole payload is one
|
|
251
|
+
consistent snapshot.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
def __init__(self, db_path: Path | str | None = None) -> None:
|
|
255
|
+
self._path = Path(db_path) if db_path is not None else DB_PATH
|
|
256
|
+
|
|
257
|
+
# ── connection ────────────────────────────────────────────────────────────
|
|
258
|
+
def _connect(self) -> sqlite3.Connection:
|
|
259
|
+
uri = f"file:{self._path}?mode=ro"
|
|
260
|
+
conn = sqlite3.connect(uri, uri=True, check_same_thread=False)
|
|
261
|
+
conn.row_factory = sqlite3.Row
|
|
262
|
+
conn.execute("PRAGMA query_only=ON")
|
|
263
|
+
return conn
|
|
264
|
+
|
|
265
|
+
# ── internal builders (take an open connection) ────────────────────────────
|
|
266
|
+
def _table(self, conn: sqlite3.Connection, table: str, order: str | None = None) -> list[dict]:
|
|
267
|
+
sql = f"SELECT * FROM {table}" # noqa: S608 — table names are literals, not user input
|
|
268
|
+
if order:
|
|
269
|
+
sql += f" ORDER BY {order}"
|
|
270
|
+
return [_decode_row(table, r) for r in conn.execute(sql).fetchall()]
|
|
271
|
+
|
|
272
|
+
def _plans(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
|
273
|
+
out: dict[str, Any] = {}
|
|
274
|
+
for r in conn.execute("SELECT assignment_id, plan_data FROM plans").fetchall():
|
|
275
|
+
try:
|
|
276
|
+
out[r["assignment_id"]] = json.loads(r["plan_data"])
|
|
277
|
+
except (json.JSONDecodeError, TypeError):
|
|
278
|
+
continue
|
|
279
|
+
return out
|
|
280
|
+
|
|
281
|
+
def _board_meta(self, conn: sqlite3.Connection) -> dict[str, str]:
|
|
282
|
+
# Served as raw strings; each client parses the keys it knows (the TUI
|
|
283
|
+
# JSON-decodes pipeline_* keys, mirroring its local-SQLite behaviour).
|
|
284
|
+
return {r["key"]: r["value"] for r in conn.execute("SELECT key, value FROM board_meta").fetchall()}
|
|
285
|
+
|
|
286
|
+
def _round_number(self, conn: sqlite3.Connection) -> int:
|
|
287
|
+
row = conn.execute("SELECT value FROM board_meta WHERE key = 'round_number'").fetchone()
|
|
288
|
+
try:
|
|
289
|
+
return int(row["value"]) if row else 0
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
def _audit_recent_count(self, conn: sqlite3.Connection) -> int:
|
|
294
|
+
"""#1037: single-integer summary for the TUI activity-bar badge —
|
|
295
|
+
rows written to ``audit_log`` in the last :data:`_AUDIT_RECENT_WINDOW_SECONDS`.
|
|
296
|
+
A real "unseen since I last looked" count needs per-client read-cursor
|
|
297
|
+
state that doesn't exist yet; a rolling recency window is the
|
|
298
|
+
forward-compatible stand-in the issue asks for (#1039 can replace the
|
|
299
|
+
semantics without a wire-shape change — it's still one integer).
|
|
300
|
+
Fail-open to 0 so a missing/pre-migration table never 503s the board.
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
cutoff = time.time() - _AUDIT_RECENT_WINDOW_SECONDS
|
|
304
|
+
row = conn.execute(
|
|
305
|
+
"SELECT COUNT(*) AS n FROM audit_log WHERE ts >= ?", (cutoff,)
|
|
306
|
+
).fetchone()
|
|
307
|
+
return int(row["n"]) if row else 0
|
|
308
|
+
except sqlite3.Error:
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
# ── public reads ────────────────────────────────────────────────────────────
|
|
312
|
+
def list_assignments(self) -> list[dict]:
|
|
313
|
+
with closing(self._connect()) as conn:
|
|
314
|
+
return self._table(conn, "assignments", order="dispatched_at DESC")
|
|
315
|
+
|
|
316
|
+
def list_machines(self) -> list[dict]:
|
|
317
|
+
with closing(self._connect()) as conn:
|
|
318
|
+
return self._table(conn, "machines", order="name")
|
|
319
|
+
|
|
320
|
+
def list_merge_queue(self) -> list[dict]:
|
|
321
|
+
with closing(self._connect()) as conn:
|
|
322
|
+
return self._table(conn, "merge_queue", order="id")
|
|
323
|
+
|
|
324
|
+
def list_drive_escalations(self) -> list[dict]:
|
|
325
|
+
with closing(self._connect()) as conn:
|
|
326
|
+
return self._table(conn, "drive_escalations", order="id")
|
|
327
|
+
|
|
328
|
+
def list_drive_queue(self) -> list[dict]:
|
|
329
|
+
with closing(self._connect()) as conn:
|
|
330
|
+
return self._table(conn, "drive_queue", order="position")
|
|
331
|
+
|
|
332
|
+
def list_proposals(self) -> list[dict]:
|
|
333
|
+
with closing(self._connect()) as conn:
|
|
334
|
+
return self._table(conn, "proposals", order="id")
|
|
335
|
+
|
|
336
|
+
def list_issues(self) -> list[dict]:
|
|
337
|
+
with closing(self._connect()) as conn:
|
|
338
|
+
return self._table(conn, "issues", order="repo_name, number")
|
|
339
|
+
|
|
340
|
+
def list_plans(self) -> dict[str, Any]:
|
|
341
|
+
with closing(self._connect()) as conn:
|
|
342
|
+
return self._plans(conn)
|
|
343
|
+
|
|
344
|
+
def list_notifications(self) -> list[dict]:
|
|
345
|
+
with closing(self._connect()) as conn:
|
|
346
|
+
return self._table(conn, "notifications")
|
|
347
|
+
|
|
348
|
+
def board_meta(self) -> dict[str, str]:
|
|
349
|
+
with closing(self._connect()) as conn:
|
|
350
|
+
return self._board_meta(conn)
|
|
351
|
+
|
|
352
|
+
def round_number(self) -> int:
|
|
353
|
+
with closing(self._connect()) as conn:
|
|
354
|
+
return self._round_number(conn)
|
|
355
|
+
|
|
356
|
+
# ── point reads (#1336/#1337) ────────────────────────────────────────────
|
|
357
|
+
def get_assignment(self, assignment_id: str) -> dict | None:
|
|
358
|
+
"""One full assignment row (JSON columns decoded), or ``None``.
|
|
359
|
+
|
|
360
|
+
Unlike the collection projection this keeps every column — including
|
|
361
|
+
``briefing`` and the unbounded free-text fields the collection wire
|
|
362
|
+
bounds/previews — because a detail read is one row by definition,
|
|
363
|
+
so it can never grow with board size. Zero third-party I/O.
|
|
364
|
+
"""
|
|
365
|
+
with closing(self._connect()) as conn:
|
|
366
|
+
row = conn.execute(
|
|
367
|
+
"SELECT * FROM assignments WHERE assignment_id = ?",
|
|
368
|
+
(assignment_id,),
|
|
369
|
+
).fetchone()
|
|
370
|
+
return _decode_row("assignments", row, full=True) if row is not None else None
|
|
371
|
+
|
|
372
|
+
def get_issue(self, repo_name: str, number: int) -> dict | None:
|
|
373
|
+
"""One full issue row (labels decoded, full ``body``), or ``None``."""
|
|
374
|
+
with closing(self._connect()) as conn:
|
|
375
|
+
row = conn.execute(
|
|
376
|
+
"SELECT * FROM issues WHERE repo_name = ? AND number = ?",
|
|
377
|
+
(repo_name, number),
|
|
378
|
+
).fetchone()
|
|
379
|
+
return _decode_row("issues", row, full=True) if row is not None else None
|
|
380
|
+
|
|
381
|
+
def _board_keep_ids(self, conn: sqlite3.Connection, cutoff: float | None) -> set[str]:
|
|
382
|
+
"""#762: assignment_ids the projection must carry (see
|
|
383
|
+
:func:`compute_board_keep_ids`)."""
|
|
384
|
+
index = [
|
|
385
|
+
dict(r)
|
|
386
|
+
for r in conn.execute(f"SELECT {_KEEP_INDEX_COLUMNS} FROM assignments").fetchall()
|
|
387
|
+
]
|
|
388
|
+
mq_ids = {
|
|
389
|
+
r["assignment_id"]
|
|
390
|
+
for r in conn.execute("SELECT assignment_id FROM merge_queue").fetchall()
|
|
391
|
+
if r["assignment_id"]
|
|
392
|
+
}
|
|
393
|
+
open_keys = {
|
|
394
|
+
(r["repo_name"], r["number"])
|
|
395
|
+
for r in conn.execute(
|
|
396
|
+
"SELECT repo_name, number FROM issues WHERE LOWER(state) != 'closed'"
|
|
397
|
+
).fetchall()
|
|
398
|
+
}
|
|
399
|
+
return compute_board_keep_ids(index, mq_ids, open_keys, cutoff)
|
|
400
|
+
|
|
401
|
+
def _capped_assignments(self, conn: sqlite3.Connection, keep: set[str] | None) -> list[dict]:
|
|
402
|
+
rows = conn.execute(
|
|
403
|
+
"SELECT * FROM assignments ORDER BY dispatched_at DESC"
|
|
404
|
+
).fetchall()
|
|
405
|
+
if keep is None:
|
|
406
|
+
return [_decode_row("assignments", r) for r in rows]
|
|
407
|
+
return [
|
|
408
|
+
_decode_row("assignments", r)
|
|
409
|
+
for r in rows
|
|
410
|
+
if r["assignment_id"] in keep
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
def _capped_notifications(
|
|
414
|
+
self, conn: sqlite3.Connection, keep: set[str] | None, cutoff: float | None
|
|
415
|
+
) -> list[dict]:
|
|
416
|
+
rows = conn.execute("SELECT * FROM notifications").fetchall()
|
|
417
|
+
if cutoff is None:
|
|
418
|
+
return [_decode_row("notifications", r) for r in rows]
|
|
419
|
+
out: list[dict] = []
|
|
420
|
+
for r in rows:
|
|
421
|
+
posted = r["posted_at"]
|
|
422
|
+
if (posted is not None and posted >= cutoff) or (
|
|
423
|
+
keep is not None and r["assignment_id"] in keep
|
|
424
|
+
):
|
|
425
|
+
out.append(_decode_row("notifications", r))
|
|
426
|
+
return out
|
|
427
|
+
|
|
428
|
+
def board_projection(self) -> dict:
|
|
429
|
+
"""The full ``GET /board`` payload — one consistent snapshot.
|
|
430
|
+
|
|
431
|
+
A superset of what the Rust TUI's ``load_data()`` reads from SQLite
|
|
432
|
+
today, minus the live machine-reachability probes (the client keeps
|
|
433
|
+
doing those itself over the tailnet).
|
|
434
|
+
|
|
435
|
+
#762: assignments + notifications are capped to active + pipeline-
|
|
436
|
+
referenced + the last ``COORD_BOARD_RETENTION_DAYS`` (default 14) of
|
|
437
|
+
terminal rows, so the wire can't grow unbounded and overrun the TUI's
|
|
438
|
+
fetch timeout (which blanks the whole board on any error).
|
|
439
|
+
"""
|
|
440
|
+
with closing(self._connect()) as conn:
|
|
441
|
+
cutoff = _board_retention_cutoff()
|
|
442
|
+
keep = self._board_keep_ids(conn, cutoff) if cutoff is not None else None
|
|
443
|
+
return {
|
|
444
|
+
"schema_version": SCHEMA_VERSION,
|
|
445
|
+
"round_number": self._round_number(conn),
|
|
446
|
+
"assignments": self._capped_assignments(conn, keep),
|
|
447
|
+
"machines": self._table(conn, "machines", order="name"),
|
|
448
|
+
"merge_queue": self._table(conn, "merge_queue", order="id"),
|
|
449
|
+
"proposals": self._table(conn, "proposals", order="id"),
|
|
450
|
+
"issues": self._table(conn, "issues", order="repo_name, number"),
|
|
451
|
+
"plans": self._plans(conn),
|
|
452
|
+
"notifications": self._capped_notifications(conn, keep, cutoff),
|
|
453
|
+
"board_meta": self._board_meta(conn),
|
|
454
|
+
"audit_recent_count": self._audit_recent_count(conn),
|
|
455
|
+
# #1505: board-visible driver-escalation records — see
|
|
456
|
+
# coord/db.py's drive_escalations table docstring. Small,
|
|
457
|
+
# unbounded-but-tiny table (one row per stuck issue, cleared
|
|
458
|
+
# on dismiss), so no retention cap like assignments/
|
|
459
|
+
# notifications above.
|
|
460
|
+
"escalations": self._table(conn, "drive_escalations", order="id"),
|
|
461
|
+
# #1753: the operator-declared `coord drive` queue, in the
|
|
462
|
+
# order it will run (dense, 0-based `position`). Same
|
|
463
|
+
# rationale as `escalations` above — bounded by hand, so no
|
|
464
|
+
# retention cap; `after_json` arrives decoded as a real list.
|
|
465
|
+
"drive_queue": self._table(conn, "drive_queue", order="position"),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
# ── writes (deferred to #590) ────────────────────────────────────────────────
|
|
469
|
+
def _not_yet(self, name: str) -> NotImplementedError:
|
|
470
|
+
return NotImplementedError(
|
|
471
|
+
f"{name}: writing through the daemon is part of #590 (route writes "
|
|
472
|
+
"through the daemon). The read-path slice (#594) serves reads only; "
|
|
473
|
+
"dispatch/record still run on the host."
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def record_result(self, record: Any) -> Any: # noqa: ARG002
|
|
477
|
+
raise self._not_yet("record_result")
|
|
478
|
+
|
|
479
|
+
def record_completion(self, record: Any) -> Any: # noqa: ARG002
|
|
480
|
+
raise self._not_yet("record_completion")
|
|
481
|
+
|
|
482
|
+
def record_dispatched(self, assignment: Any) -> None: # noqa: ARG002
|
|
483
|
+
raise self._not_yet("record_dispatched")
|
|
File without changes
|