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
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Producer-side guard against the #632 blank-board class (#748).
|
|
2
|
+
|
|
3
|
+
SQLite has no native boolean type — a column declared ``INTEGER DEFAULT 0``
|
|
4
|
+
and used as a flag (e.g. ``is_interactive``) serializes on the `/board` wire
|
|
5
|
+
as a raw JSON ``0``/``1``, not ``true``/``false``. The Rust side
|
|
6
|
+
(``tui/src/app/types.rs``) hand-mirrors DB columns into typed structs; if a
|
|
7
|
+
*new* such column is ever typed as a plain ``bool`` there (instead of going
|
|
8
|
+
through ``de_bool_from_int_or_bool`` or an equivalent coercing
|
|
9
|
+
deserializer), that ONE field fails the parse of the **entire**
|
|
10
|
+
``BoardPayload`` and blanks the whole TUI board (#632/#546/#628).
|
|
11
|
+
|
|
12
|
+
This module is the producer-side (Python) half of the seam check: it reads
|
|
13
|
+
the Rust struct definitions as text, finds `bool`-typed fields with no
|
|
14
|
+
custom deserializer, and cross-references them against the live SQLite
|
|
15
|
+
schema. ``tests/test_board_fixture.py`` wires this up against the real
|
|
16
|
+
``tui/src/app/types.rs`` + a freshly-migrated DB so CI goes red the moment
|
|
17
|
+
someone adds an unguarded INTEGER-backed bool field.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
# Matches `#[attr] #[attr] ... pub(crate) <name>: bool,` — i.e. a struct
|
|
25
|
+
# field typed as a plain `bool` (not `Option<bool>`, not some wrapper type).
|
|
26
|
+
# `attrs` captures every `#[...]` attribute line directly above the field so
|
|
27
|
+
# we can check it for a `deserialize_with` guard and/or a `rename`.
|
|
28
|
+
_BOOL_FIELD_RE = re.compile(
|
|
29
|
+
r"(?P<attrs>(?:[ \t]*#\[[^\]]*\]\s*\n)*)"
|
|
30
|
+
r"[ \t]*pub\(crate\)\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*):\s*bool,",
|
|
31
|
+
)
|
|
32
|
+
_RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_unguarded_bool_fields(rust_src: str) -> dict[str, str]:
|
|
36
|
+
"""Return ``{wire_key: field_name}`` for `bool` struct fields in *rust_src*
|
|
37
|
+
that have no `deserialize_with` attribute.
|
|
38
|
+
|
|
39
|
+
Such fields require the wire to send a literal JSON `true`/`false` — a
|
|
40
|
+
SQLite-style `0`/`1` integer fails the whole containing struct's parse.
|
|
41
|
+
"""
|
|
42
|
+
out: dict[str, str] = {}
|
|
43
|
+
for m in _BOOL_FIELD_RE.finditer(rust_src):
|
|
44
|
+
attrs = m.group("attrs")
|
|
45
|
+
if "deserialize_with" in attrs:
|
|
46
|
+
continue # guarded — e.g. de_bool_from_int_or_bool
|
|
47
|
+
name = m.group("name")
|
|
48
|
+
rename = _RENAME_RE.search(attrs)
|
|
49
|
+
wire_key = rename.group(1) if rename else name
|
|
50
|
+
out[wire_key] = name
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_integer_bool_mismatches(
|
|
55
|
+
rust_src: str, schema_columns: dict[str, dict[str, str]]
|
|
56
|
+
) -> list[str]:
|
|
57
|
+
"""Cross-reference unguarded Rust `bool` fields against SQLite column types.
|
|
58
|
+
|
|
59
|
+
``schema_columns`` maps table name -> ``{column_name: declared_type}``
|
|
60
|
+
(as ``PRAGMA table_info`` reports it, e.g. ``"INTEGER"``, ``"TEXT"``).
|
|
61
|
+
|
|
62
|
+
Returns ``"<table>.<column>"`` for every unguarded `bool` field whose
|
|
63
|
+
wire key matches a column declared ``INTEGER`` — the exact #632 trigger.
|
|
64
|
+
Empty list means the seam is safe.
|
|
65
|
+
"""
|
|
66
|
+
unguarded = find_unguarded_bool_fields(rust_src)
|
|
67
|
+
mismatches: list[str] = []
|
|
68
|
+
for table, cols in sorted(schema_columns.items()):
|
|
69
|
+
for col, decl_type in sorted(cols.items()):
|
|
70
|
+
if col in unguarded and decl_type.upper().startswith("INTEGER"):
|
|
71
|
+
mismatches.append(f"{table}.{col}")
|
|
72
|
+
return mismatches
|
coord/board_service.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""``BoardService`` facade (#749): decide local-vs-daemon ONCE, for both reads
|
|
2
|
+
and writes, instead of re-implementing the ``resolve_board_service()`` +
|
|
3
|
+
local-fallback dance at every call site.
|
|
4
|
+
|
|
5
|
+
Before this module, ~30 call sites across ``coord/commands/*.py``,
|
|
6
|
+
``coord/dashboard/server.py`` and ``coord/auto_loop.py`` each hand-rolled:
|
|
7
|
+
|
|
8
|
+
svc = resolve_board_service()
|
|
9
|
+
board = fetch_remote_board(svc) if svc else (load_board() or build_board())
|
|
10
|
+
...mutate board...
|
|
11
|
+
save_board(board) # silently a no-op on a thin client — the #749 bug
|
|
12
|
+
|
|
13
|
+
``read_board()`` / ``write_board()`` below are the one place that decision is
|
|
14
|
+
made. ``write_board()`` POSTs to the daemon's generic ``/board`` upsert
|
|
15
|
+
endpoint when ``board_service`` is configured, so a thin client's mutation
|
|
16
|
+
actually reaches the shared DB instead of vanishing into an empty local one.
|
|
17
|
+
|
|
18
|
+
This module also hosts ``route_write()``, which centralizes the
|
|
19
|
+
``resolve_board_service()`` + ``coord.client.post_record`` dance duplicated
|
|
20
|
+
~13x across ``coord.state``'s per-mutation routing wrappers
|
|
21
|
+
(``record_dispatched``, ``record_test_verdict``, ``update_issue_labels``,
|
|
22
|
+
…) — so ``coord.state`` depends on this thin facade rather than importing
|
|
23
|
+
``coord.client`` directly at over a dozen sites (#749's "reduce state.py's
|
|
24
|
+
outward coupling toward a pure DAO").
|
|
25
|
+
|
|
26
|
+
Whole-command re-routing (``coord merge``, ``coord reconcile-merges``,
|
|
27
|
+
``coord diagnose``, ``coord housekeeping`` — where the ENTIRE command runs on
|
|
28
|
+
the daemon and streams back its own textual/structured output, rather than a
|
|
29
|
+
generic board upsert) is a different shape and stays in its own commands;
|
|
30
|
+
``daemon_reroute_target()`` here only DRYs up the repeated
|
|
31
|
+
"resolve + check the re-entrancy env guard" preamble those four call sites
|
|
32
|
+
share.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import os
|
|
38
|
+
|
|
39
|
+
from coord.models import Board
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve(): # -> coord.client.ServiceConfig | None
|
|
43
|
+
"""Resolve the configured board service, or ``None`` for local/host mode."""
|
|
44
|
+
from coord.client import resolve_board_service # noqa: PLC0415
|
|
45
|
+
|
|
46
|
+
return resolve_board_service()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_remote() -> bool:
|
|
50
|
+
"""Whether this process is a thin client (``board_service`` configured)."""
|
|
51
|
+
return resolve() is not None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_board() -> Board:
|
|
55
|
+
"""Return the current board.
|
|
56
|
+
|
|
57
|
+
Daemon-configured → GET /board and reconstruct it. Otherwise → the local
|
|
58
|
+
DB (``load_board()``, falling back to ``build_board()`` when the board has
|
|
59
|
+
never been saved). This is the single place that decision is made; every
|
|
60
|
+
call site that used to duplicate
|
|
61
|
+
``fetch_remote_board(svc) if svc else (load_board() or build_board())``
|
|
62
|
+
should call this instead.
|
|
63
|
+
"""
|
|
64
|
+
svc = resolve()
|
|
65
|
+
if svc is not None:
|
|
66
|
+
from coord.client import fetch_remote_board # noqa: PLC0415
|
|
67
|
+
|
|
68
|
+
return fetch_remote_board(svc)
|
|
69
|
+
from coord.state import build_board, load_board # noqa: PLC0415
|
|
70
|
+
|
|
71
|
+
return load_board() or build_board()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def write_board(board: Board) -> None:
|
|
75
|
+
"""Persist *board*.
|
|
76
|
+
|
|
77
|
+
Daemon-configured → POST the full board to the daemon's ``/board`` upsert
|
|
78
|
+
endpoint (safe: upsert-only, never deletes — see
|
|
79
|
+
``coord.client.serialize_board``). Otherwise → the local
|
|
80
|
+
``coord.state.save_board``. Every call site that used to call
|
|
81
|
+
``save_board(board)`` directly (and silently no-op on a thin client)
|
|
82
|
+
should call this instead.
|
|
83
|
+
"""
|
|
84
|
+
svc = resolve()
|
|
85
|
+
if svc is not None:
|
|
86
|
+
from coord.client import post_board # noqa: PLC0415
|
|
87
|
+
|
|
88
|
+
post_board(svc, board)
|
|
89
|
+
return
|
|
90
|
+
from coord.state import save_board # noqa: PLC0415
|
|
91
|
+
|
|
92
|
+
save_board(board)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def route_write(
|
|
96
|
+
svc,
|
|
97
|
+
endpoint: str,
|
|
98
|
+
payload: dict,
|
|
99
|
+
*,
|
|
100
|
+
timeout: float | None = None,
|
|
101
|
+
) -> dict | None:
|
|
102
|
+
"""POST *payload* to *endpoint* via *svc*, or ``None`` if *svc* is unset.
|
|
103
|
+
|
|
104
|
+
Centralizes the ``from coord.client import post_record`` + call dance
|
|
105
|
+
duplicated across ``coord.state``'s ``record_*`` / ``update_*`` routing
|
|
106
|
+
wrappers, so ``coord.state`` no longer needs its own deferred
|
|
107
|
+
``coord.client`` import at each of those ~13 sites. Callers keep their own
|
|
108
|
+
``svc = board_service.resolve()`` (they need the value regardless, to
|
|
109
|
+
decide whether to fall through to the local ``_*_local`` path) — this just
|
|
110
|
+
does the "POST it, or tell the caller there's nothing to POST to" part.
|
|
111
|
+
|
|
112
|
+
Returns ``None`` when *svc* is ``None`` (caller should run the local
|
|
113
|
+
path); otherwise returns the daemon's JSON response (never ``None``,
|
|
114
|
+
even for an empty ``{}`` body) so callers can distinguish "routed" from
|
|
115
|
+
"not routed" with a plain ``is not None`` check.
|
|
116
|
+
"""
|
|
117
|
+
if svc is None:
|
|
118
|
+
return None
|
|
119
|
+
from coord.client import post_record # noqa: PLC0415
|
|
120
|
+
|
|
121
|
+
if timeout is None:
|
|
122
|
+
return post_record(svc, endpoint, payload)
|
|
123
|
+
return post_record(svc, endpoint, payload, timeout=timeout)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def daemon_reroute_target(env_var: str): # -> coord.client.ServiceConfig | None
|
|
127
|
+
"""Resolve the board service for a whole-command daemon re-route, or
|
|
128
|
+
``None`` if the command should run locally.
|
|
129
|
+
|
|
130
|
+
Shared preamble for the four commands (``merge``, ``reconcile-merges``,
|
|
131
|
+
``diagnose``, ``housekeeping``) that route their ENTIRE execution to the
|
|
132
|
+
daemon rather than doing a generic board upsert: each sets *env_var*
|
|
133
|
+
(e.g. ``COORD_MERGE_ON_DAEMON``) before re-invoking itself on the daemon
|
|
134
|
+
side, so the daemon's own execution doesn't try to re-route back out over
|
|
135
|
+
HTTP. Returns ``None`` (meaning "run locally") both when no board service
|
|
136
|
+
is configured AND when *env_var* is set (i.e. we ARE the daemon running
|
|
137
|
+
the re-routed-to invocation).
|
|
138
|
+
"""
|
|
139
|
+
if os.environ.get(env_var):
|
|
140
|
+
return None
|
|
141
|
+
return resolve()
|
coord/board_wire.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Wire-bounding policy for the ``/board`` collection payload (#1337).
|
|
2
|
+
|
|
3
|
+
**Invariant 2 of the /board read path: no collection endpoint returns
|
|
4
|
+
unbounded text.** The #762 fix bounded the board's *row count* (retention
|
|
5
|
+
cap) — and the payload kept growing anyway, because the growth was in
|
|
6
|
+
per-row text size: ``assignments.review_findings`` + ``issues.body`` alone
|
|
7
|
+
were ~46 % of a 5.5 MB payload polled every few seconds. Bounding rows
|
|
8
|
+
while text grows per-row is how this failure class (#762 → #715 → #1336)
|
|
9
|
+
kept coming back.
|
|
10
|
+
|
|
11
|
+
This module is the single place the per-field wire policy lives:
|
|
12
|
+
|
|
13
|
+
* **Preview fields** — ``review_findings`` (body inside the JSON envelope),
|
|
14
|
+
``test_reason``, ``smoke_test_reason`` — are cut to a preview and flagged
|
|
15
|
+
with ``<field>_truncated: true`` (+ ``<field>_len``). Consumers that need
|
|
16
|
+
the full text (fix-worker briefings, the TUI findings pane) fetch the
|
|
17
|
+
single-resource detail endpoint ``GET /assignment/{id}``.
|
|
18
|
+
* **Bounded documents** — ``issues.body``, ``test_plan`` — get a *high* hard
|
|
19
|
+
cap that today truncates nothing real (issue-body p99 ≈ 9 KB, cap 16 KB)
|
|
20
|
+
but bounds the pathological row, because clients parse these semantically
|
|
21
|
+
(work orders, ``## Files`` globs) and an aggressive prefix cut would break
|
|
22
|
+
those parses. **Tracking (epic) issue bodies are exempt entirely** — the
|
|
23
|
+
TUI's Milestone DAG parses ``## Work order`` client-side out of the full
|
|
24
|
+
body (see :func:`bound_issue_row`); GitHub's own 64 KB issue-body limit ×
|
|
25
|
+
the handful of epics per board keeps the exemption bounded. Full body:
|
|
26
|
+
``GET /issue/{repo}/{number}``.
|
|
27
|
+
|
|
28
|
+
Everything here is wire-only: the DB row, the detail endpoints, and local
|
|
29
|
+
(non-daemon) reads are untouched. The bounded fields are also excluded from
|
|
30
|
+
the whole-board upsert's UPDATE clause (``coord.state._UPSERT_SQL``), so a
|
|
31
|
+
bounded preview can never round-trip over the full stored text via
|
|
32
|
+
``POST /board``.
|
|
33
|
+
|
|
34
|
+
Enforced by tests/test_board_read_path.py — a payload-budget test fails the
|
|
35
|
+
suite if a seeded board's wire exceeds its budget, so instance #4 of this
|
|
36
|
+
class shows up as a red test, not a fleet incident.
|
|
37
|
+
|
|
38
|
+
**#1791 (instance #4): bounding row WIDTH was not enough.** #762 added a
|
|
39
|
+
day-based retention cutoff at the DAO layer (``COORD_BOARD_RETENTION_DAYS``,
|
|
40
|
+
default 14 — see ``coord/dao.py``), but a normal ~2-week stretch of fleet
|
|
41
|
+
throughput still produced 904 terminal (done/merged/failed/advisory)
|
|
42
|
+
assignment rows inside that window — none of them "old" yet — and 904 small,
|
|
43
|
+
individually-width-bounded rows landed at the same 5.30 MB payload 90 large
|
|
44
|
+
ones did pre-#1337. ``bound_board_payload`` below adds the collection-
|
|
45
|
+
CARDINALITY bound this class of fix has been missing: it caps the
|
|
46
|
+
*count* of terminal assignment rows on the wire
|
|
47
|
+
(:data:`MAX_TERMINAL_ASSIGNMENTS`) and drops the body of closed (terminal)
|
|
48
|
+
issues outright, on top of the existing per-field width caps. Both cuts are
|
|
49
|
+
flagged on the payload (``board_truncated`` + counts) so a client can tell
|
|
50
|
+
it received a trimmed board rather than the whole history — see
|
|
51
|
+
:func:`bound_board_payload`.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
|
|
58
|
+
from coord.dao import TERMINAL_STATUSES
|
|
59
|
+
|
|
60
|
+
# Preview size for operator-facing free text. Large enough that a short
|
|
61
|
+
# review / test reason arrives whole; everything longer is a preview + flag.
|
|
62
|
+
PREVIEW_CHARS = 2000
|
|
63
|
+
# Hard cap for semantically-parsed documents (issue bodies, test plans).
|
|
64
|
+
DOCUMENT_CHARS = 16384
|
|
65
|
+
|
|
66
|
+
# #1791: how many *terminal* assignment rows the /board wire carries. This is
|
|
67
|
+
# a SECOND, tighter bound than #762's day-based DAO cutoff — that cap bounds
|
|
68
|
+
# board AGE, not board THROUGHPUT, so a busy fortnight still puts every one
|
|
69
|
+
# of its terminal rows on the wire because none of them are "old" yet. Only
|
|
70
|
+
# the most recent MAX_TERMINAL_ASSIGNMENTS terminal rows (by finished_at,
|
|
71
|
+
# falling back to dispatched_at) ride the wire; active (non-terminal) rows
|
|
72
|
+
# and the latest assignment of a still-open issue are NEVER subject to this
|
|
73
|
+
# cap, regardless of how many terminal rows exist. Full history stays
|
|
74
|
+
# reachable via GET /assignment/{id}.
|
|
75
|
+
MAX_TERMINAL_ASSIGNMENTS = 200
|
|
76
|
+
|
|
77
|
+
# #1791: named byte budget for the WHOLE /board payload. #1337 bounded
|
|
78
|
+
# per-row WIDTH only (``BOARD payload budget`` in tests/test_board_read_path.py
|
|
79
|
+
# guards that), so row CARDINALITY could still blow the same ceiling that
|
|
80
|
+
# broke `coord report-result`'s 5s prefetch timeout in #1336 — exactly what
|
|
81
|
+
# recurred in #1791 (5.30 MB, 904 of 906 assignment rows terminal). Enforced
|
|
82
|
+
# by tests/test_board_wire.py::test_board_payload_budget_holds_at_terminal_row_scale,
|
|
83
|
+
# seeded with THOUSANDS of terminal rows, so a fifth recurrence fails a
|
|
84
|
+
# test, not a fleet check.
|
|
85
|
+
BOARD_PAYLOAD_BYTE_BUDGET = 2_500_000
|
|
86
|
+
|
|
87
|
+
# Appended to truncated *plain-text* fields so a human reading the preview
|
|
88
|
+
# (TUI pane, dialog) knows it is one — machine consumers use the flags.
|
|
89
|
+
TRUNCATION_NOTICE = "\n… [truncated on the /board wire — full text: detail endpoint]"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _preview(text: str, cap: int) -> str:
|
|
93
|
+
return text[:cap] + TRUNCATION_NOTICE
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bound_text_field(row: dict, field: str, cap: int) -> None:
|
|
97
|
+
"""Truncate ``row[field]`` to *cap* chars, stamping ``<field>_truncated``
|
|
98
|
+
and ``<field>_len`` when it was cut. Flags are additive-only (absent when
|
|
99
|
+
nothing was cut) so old clients see an unchanged shape."""
|
|
100
|
+
val = row.get(field)
|
|
101
|
+
if not isinstance(val, str) or len(val) <= cap:
|
|
102
|
+
return
|
|
103
|
+
row[f"{field}_len"] = len(val)
|
|
104
|
+
row[f"{field}_truncated"] = True
|
|
105
|
+
row[field] = _preview(val, cap)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _bound_review_findings(row: dict, cap: int) -> None:
|
|
109
|
+
"""Envelope-aware preview for ``review_findings``.
|
|
110
|
+
|
|
111
|
+
The column is a JSON envelope ``{"verdict": ..., "body": ...}`` kept as a
|
|
112
|
+
*raw string* on the wire (the TUI parses it). Truncating the raw string
|
|
113
|
+
would corrupt the JSON, so parse, preview the body inside, and
|
|
114
|
+
re-serialize — the verdict always survives intact. A legacy/unparseable
|
|
115
|
+
blob falls back to plain-text truncation.
|
|
116
|
+
"""
|
|
117
|
+
raw = row.get("review_findings")
|
|
118
|
+
if not isinstance(raw, str) or len(raw) <= cap:
|
|
119
|
+
return
|
|
120
|
+
row["review_findings_len"] = len(raw)
|
|
121
|
+
row["review_findings_truncated"] = True
|
|
122
|
+
try:
|
|
123
|
+
env = json.loads(raw)
|
|
124
|
+
except (json.JSONDecodeError, TypeError):
|
|
125
|
+
env = None
|
|
126
|
+
if isinstance(env, dict) and isinstance(env.get("body"), str):
|
|
127
|
+
env["body"] = _preview(env["body"], cap)
|
|
128
|
+
env["truncated"] = True
|
|
129
|
+
row["review_findings"] = json.dumps(env)
|
|
130
|
+
else:
|
|
131
|
+
row["review_findings"] = raw[:cap]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _bound_test_plan(row: dict, cap: int) -> None:
|
|
135
|
+
"""``test_plan`` is a decoded ``{"steps": [...]}`` object on the wire —
|
|
136
|
+
a prefix cut is meaningless, so a pathological plan is dropped whole
|
|
137
|
+
(flagged); the TUI's existing ``None`` handling shows its placeholder and
|
|
138
|
+
the detail endpoint serves the full plan."""
|
|
139
|
+
val = row.get("test_plan")
|
|
140
|
+
if val is None:
|
|
141
|
+
return
|
|
142
|
+
try:
|
|
143
|
+
size = len(json.dumps(val))
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return
|
|
146
|
+
if size <= cap:
|
|
147
|
+
return
|
|
148
|
+
row["test_plan_len"] = size
|
|
149
|
+
row["test_plan_truncated"] = True
|
|
150
|
+
row["test_plan"] = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def bound_assignment_row(row: dict) -> None:
|
|
154
|
+
"""Apply the wire policy to one ``/board`` assignment row (mutates)."""
|
|
155
|
+
_bound_review_findings(row, PREVIEW_CHARS)
|
|
156
|
+
_bound_text_field(row, "test_reason", PREVIEW_CHARS)
|
|
157
|
+
_bound_text_field(row, "smoke_test_reason", PREVIEW_CHARS)
|
|
158
|
+
_bound_text_field(row, "failure_reason", PREVIEW_CHARS)
|
|
159
|
+
_bound_test_plan(row, DOCUMENT_CHARS)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_tracking_issue(row: dict) -> bool:
|
|
163
|
+
"""True when the issue carries the milestone-tracking (epic) label."""
|
|
164
|
+
from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
|
|
165
|
+
|
|
166
|
+
labels = row.get("labels")
|
|
167
|
+
return isinstance(labels, list) and TRACKING_ISSUE_LABEL in labels
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_closed_issue(row: dict) -> bool:
|
|
171
|
+
state = row.get("state")
|
|
172
|
+
return isinstance(state, str) and state.strip().lower() == "closed"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def bound_issue_row(row: dict) -> None:
|
|
176
|
+
"""Apply the wire policy to one ``/board`` issue row (mutates).
|
|
177
|
+
|
|
178
|
+
**Tracking (epic) issues are exempt from the body cap.** The TUI's
|
|
179
|
+
Milestone DAG parses ``## Work order`` out of the tracking issue's body
|
|
180
|
+
*client-side* (`milestone_dag.rs::milestones_with_work_orders` — it does
|
|
181
|
+
NOT consume the server-computed ``milestone_work_orders``, which drops
|
|
182
|
+
terminal nodes and carries no ``after``-edges), so a cap that cuts
|
|
183
|
+
work-order items past DOCUMENT_CHARS would silently drop DAG nodes on
|
|
184
|
+
thin clients — a regression in exactly the failure class #1337 exists to
|
|
185
|
+
close. The exemption stays bounded in practice: an epic body is capped
|
|
186
|
+
at 65,536 chars by GitHub itself and boards carry few epics (~31 today,
|
|
187
|
+
0.19 MB total).
|
|
188
|
+
|
|
189
|
+
**Closed (non-epic) issues drop the body entirely** (#1791): a closed
|
|
190
|
+
issue is terminal — no pipeline decision, client-side or server-side,
|
|
191
|
+
reads its body once it's closed — and #1337's DOCUMENT_CHARS cap alone
|
|
192
|
+
still left 110 closed issues carrying 0.57 MB on every /board GET. Full
|
|
193
|
+
text stays on ``GET /issue/{repo}/{number}``. Open (non-epic) issue
|
|
194
|
+
bodies — the ones ``pipeline.rs``'s ``acceptance_for_path_arg`` and
|
|
195
|
+
``dialogs.rs`` parse client-side — keep the DOCUMENT_CHARS cap
|
|
196
|
+
unchanged.
|
|
197
|
+
"""
|
|
198
|
+
if _is_tracking_issue(row):
|
|
199
|
+
return
|
|
200
|
+
if _is_closed_issue(row):
|
|
201
|
+
_bound_text_field(row, "body", 0)
|
|
202
|
+
return
|
|
203
|
+
_bound_text_field(row, "body", DOCUMENT_CHARS)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _open_issue_keys(issues) -> set[tuple[str, int]]:
|
|
207
|
+
"""``(repo_name, number)`` of every non-closed issue in the projection."""
|
|
208
|
+
keys: set[tuple[str, int]] = set()
|
|
209
|
+
for row in issues:
|
|
210
|
+
if not isinstance(row, dict) or _is_closed_issue(row):
|
|
211
|
+
continue
|
|
212
|
+
repo_name, number = row.get("repo_name"), row.get("number")
|
|
213
|
+
if repo_name is not None and number is not None:
|
|
214
|
+
keys.add((repo_name, number))
|
|
215
|
+
return keys
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def cap_terminal_assignments(
|
|
219
|
+
assignments: list[dict], open_issue_keys: set[tuple[str, int]]
|
|
220
|
+
) -> int:
|
|
221
|
+
"""Cap ``assignments`` (mutated in place) to :data:`MAX_TERMINAL_ASSIGNMENTS`
|
|
222
|
+
terminal rows (#1791). Returns the number of rows dropped.
|
|
223
|
+
|
|
224
|
+
A row is PROTECTED — never dropped, regardless of how many terminal rows
|
|
225
|
+
exist — when it is active (status not in ``TERMINAL_STATUSES``) or is
|
|
226
|
+
tied to a still-open issue. That mirrors
|
|
227
|
+
``coord.dao.compute_board_keep_ids``'s "latest assignment of an open
|
|
228
|
+
issue" rule, so this second, tighter cut can never undo that guarantee.
|
|
229
|
+
Among the remaining terminal rows, the most recent
|
|
230
|
+
``MAX_TERMINAL_ASSIGNMENTS`` (by ``finished_at``, falling back to
|
|
231
|
+
``dispatched_at``) are kept; the cut is then closed over
|
|
232
|
+
``review_of_assignment_id`` in both directions — same closure rule as
|
|
233
|
+
``compute_board_keep_ids`` — so an in-flight review never loses its
|
|
234
|
+
target and a kept row never loses an in-flight review.
|
|
235
|
+
"""
|
|
236
|
+
original_count = len(assignments)
|
|
237
|
+
protected: list[dict] = []
|
|
238
|
+
terminal: list[dict] = []
|
|
239
|
+
for row in assignments:
|
|
240
|
+
status = (row.get("status") or "").lower()
|
|
241
|
+
key = (row.get("repo_name"), row.get("issue_number"))
|
|
242
|
+
if status not in TERMINAL_STATUSES or key in open_issue_keys:
|
|
243
|
+
protected.append(row)
|
|
244
|
+
else:
|
|
245
|
+
terminal.append(row)
|
|
246
|
+
|
|
247
|
+
if len(terminal) <= MAX_TERMINAL_ASSIGNMENTS:
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
terminal.sort(
|
|
251
|
+
key=lambda r: r.get("finished_at") or r.get("dispatched_at") or 0.0,
|
|
252
|
+
reverse=True,
|
|
253
|
+
)
|
|
254
|
+
keep_ids = {
|
|
255
|
+
r.get("assignment_id")
|
|
256
|
+
for r in protected + terminal[:MAX_TERMINAL_ASSIGNMENTS]
|
|
257
|
+
if r.get("assignment_id")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
by_id = {r.get("assignment_id"): r for r in assignments if r.get("assignment_id")}
|
|
261
|
+
reviews_of: dict[str, list[str]] = {}
|
|
262
|
+
for aid, r in by_id.items():
|
|
263
|
+
tgt = r.get("review_of_assignment_id")
|
|
264
|
+
if tgt:
|
|
265
|
+
reviews_of.setdefault(tgt, []).append(aid)
|
|
266
|
+
frontier = list(keep_ids)
|
|
267
|
+
while frontier:
|
|
268
|
+
aid = frontier.pop()
|
|
269
|
+
tgt = by_id.get(aid, {}).get("review_of_assignment_id")
|
|
270
|
+
if tgt and tgt in by_id and tgt not in keep_ids:
|
|
271
|
+
keep_ids.add(tgt)
|
|
272
|
+
frontier.append(tgt)
|
|
273
|
+
for rev in reviews_of.get(aid, ()):
|
|
274
|
+
if rev not in keep_ids:
|
|
275
|
+
keep_ids.add(rev)
|
|
276
|
+
frontier.append(rev)
|
|
277
|
+
|
|
278
|
+
assignments[:] = [r for r in assignments if r.get("assignment_id") in keep_ids]
|
|
279
|
+
return original_count - len(assignments)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def bound_board_payload(projection: dict) -> None:
|
|
283
|
+
"""Bound every unbounded free-text field AND the collection's row count
|
|
284
|
+
in a ``/board`` projection (#1337 bounded width; #1791 adds cardinality).
|
|
285
|
+
|
|
286
|
+
Called by the daemon's board builder AFTER the derived sections
|
|
287
|
+
(milestone work orders, plan roster, epic children) are computed — those
|
|
288
|
+
parse full issue bodies server-side and must see them unbounded.
|
|
289
|
+
"""
|
|
290
|
+
issues = projection.get("issues")
|
|
291
|
+
assignments = projection.get("assignments")
|
|
292
|
+
|
|
293
|
+
dropped = 0
|
|
294
|
+
if isinstance(assignments, list) and isinstance(issues, (list, tuple)):
|
|
295
|
+
dropped = cap_terminal_assignments(assignments, _open_issue_keys(issues))
|
|
296
|
+
|
|
297
|
+
for row in projection.get("assignments", ()):
|
|
298
|
+
if isinstance(row, dict):
|
|
299
|
+
bound_assignment_row(row)
|
|
300
|
+
for row in projection.get("issues", ()):
|
|
301
|
+
if isinstance(row, dict):
|
|
302
|
+
bound_issue_row(row)
|
|
303
|
+
|
|
304
|
+
# #1791: truncation must be visible to clients — additive-only, same
|
|
305
|
+
# convention as the per-field ``<field>_truncated`` flags above, so a
|
|
306
|
+
# client that never received a trimmed board sees an unchanged shape.
|
|
307
|
+
if dropped:
|
|
308
|
+
projection["board_truncated"] = True
|
|
309
|
+
projection["board_truncated_assignments"] = dropped
|