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/openapi.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Shared OpenAPI 3 builder for the three hand-rolled Starlette apps (#757).
|
|
2
|
+
|
|
3
|
+
``coord/agent_app.py`` (7433), ``coord/serve_app.py`` (7435), and
|
|
4
|
+
``coord/dashboard/server.py`` (7434) each declare a plain list of Starlette
|
|
5
|
+
``Route`` objects with no machine-readable contract. This module gives every
|
|
6
|
+
app a way to serve ``GET /openapi.json`` + a browsable ``GET /docs`` page
|
|
7
|
+
(Swagger UI) built from the *same* Python types that already define the wire
|
|
8
|
+
contract, mirroring the #750 codegen approach (introspect
|
|
9
|
+
``dataclasses.fields()`` / ``typing.get_type_hints()`` rather than
|
|
10
|
+
hand-writing a parallel schema):
|
|
11
|
+
|
|
12
|
+
- :func:`dataclass_schema` walks a dataclass (recursively, for nested
|
|
13
|
+
dataclass fields) into a JSON Schema, registering it under
|
|
14
|
+
``components/schemas`` and returning a ``$ref``. This is what fully
|
|
15
|
+
specifies ``POST /assign`` (``AssignmentSpec`` request / ``AgentAssignment``
|
|
16
|
+
response) on the agent app.
|
|
17
|
+
- :func:`sqlite_table_schema` builds a JSON Schema straight from
|
|
18
|
+
``PRAGMA table_info`` for a live (migrated) SQLite connection — because, per
|
|
19
|
+
``scripts/gen_board_fixture.py``, the daemon's ``/board`` wire schema *is*
|
|
20
|
+
the SQLite DDL (``coord/db.py``), not a dataclass. This fully specifies
|
|
21
|
+
``GET /board`` on the daemon app.
|
|
22
|
+
- :func:`build_spec` assembles the OpenAPI 3.0.3 document.
|
|
23
|
+
- :func:`openapi_and_docs_routes` returns the two ``Route`` objects
|
|
24
|
+
(``/openapi.json`` serving the spec, ``/docs`` serving a Swagger UI page)
|
|
25
|
+
every ``build_app()`` appends to its route list.
|
|
26
|
+
- :func:`declared_routes` / :func:`spec_routes` extract ``(method, path)``
|
|
27
|
+
sets from, respectively, the real Starlette route table and the generated
|
|
28
|
+
spec, so a test can assert they're identical and the spec can't silently
|
|
29
|
+
drift from the actual routes (the #757 acceptance criterion).
|
|
30
|
+
|
|
31
|
+
This is the intended input for #750's codegen: once a surface's OpenAPI
|
|
32
|
+
``components/schemas`` are populated here, the TS/Rust generators can point
|
|
33
|
+
at ``GET /openapi.json`` instead of (or in addition to) introspecting the
|
|
34
|
+
Python dataclasses directly.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import dataclasses
|
|
40
|
+
import sqlite3
|
|
41
|
+
import types as _types
|
|
42
|
+
import typing
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from starlette.requests import Request
|
|
46
|
+
from starlette.responses import HTMLResponse, JSONResponse
|
|
47
|
+
from starlette.routing import BaseRoute, Route
|
|
48
|
+
|
|
49
|
+
OPENAPI_VERSION = "3.0.3"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── dataclass → JSON Schema ──────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def _scalar_schema(tp: object) -> dict[str, Any] | None:
|
|
55
|
+
if tp is str:
|
|
56
|
+
return {"type": "string"}
|
|
57
|
+
if tp is bool:
|
|
58
|
+
return {"type": "boolean"}
|
|
59
|
+
if tp is int:
|
|
60
|
+
return {"type": "integer"}
|
|
61
|
+
if tp is float:
|
|
62
|
+
return {"type": "number"}
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def json_schema_for(tp: object, components: dict[str, Any]) -> dict[str, Any]:
|
|
67
|
+
"""Map a resolved Python type (``typing.get_type_hints`` output) to a
|
|
68
|
+
JSON Schema fragment, registering any nested dataclass into
|
|
69
|
+
``components`` and returning a ``$ref`` for it.
|
|
70
|
+
|
|
71
|
+
Mirrors ``scripts/codegen.py``'s ``ts_type()`` structurally, just
|
|
72
|
+
targeting JSON Schema (OpenAPI 3.0's dialect — ``nullable: true`` rather
|
|
73
|
+
than a ``"null"`` member of a ``type`` array) instead of TypeScript.
|
|
74
|
+
"""
|
|
75
|
+
if tp is type(None):
|
|
76
|
+
return {"type": "null"}
|
|
77
|
+
if tp is typing.Any:
|
|
78
|
+
return {}
|
|
79
|
+
if isinstance(tp, type):
|
|
80
|
+
scalar = _scalar_schema(tp)
|
|
81
|
+
if scalar is not None:
|
|
82
|
+
return scalar
|
|
83
|
+
if dataclasses.is_dataclass(tp):
|
|
84
|
+
return dataclass_schema(tp, components)
|
|
85
|
+
if tp is dict:
|
|
86
|
+
return {"type": "object"}
|
|
87
|
+
if tp is list:
|
|
88
|
+
return {"type": "array", "items": {}}
|
|
89
|
+
|
|
90
|
+
origin = typing.get_origin(tp)
|
|
91
|
+
args = typing.get_args(tp)
|
|
92
|
+
|
|
93
|
+
if origin in (list, typing.List): # noqa: UP006
|
|
94
|
+
(inner,) = args
|
|
95
|
+
return {"type": "array", "items": json_schema_for(inner, components)}
|
|
96
|
+
if origin in (dict, typing.Dict): # noqa: UP006
|
|
97
|
+
if len(args) == 2:
|
|
98
|
+
return {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"additionalProperties": json_schema_for(args[1], components),
|
|
101
|
+
}
|
|
102
|
+
return {"type": "object"}
|
|
103
|
+
if origin is typing.Union or origin is _types.UnionType:
|
|
104
|
+
non_none = [a for a in args if a is not type(None)]
|
|
105
|
+
nullable = len(non_none) != len(args)
|
|
106
|
+
if len(non_none) == 1:
|
|
107
|
+
schema = dict(json_schema_for(non_none[0], components))
|
|
108
|
+
else:
|
|
109
|
+
schema = {"anyOf": [json_schema_for(a, components) for a in non_none]}
|
|
110
|
+
if nullable:
|
|
111
|
+
schema["nullable"] = True
|
|
112
|
+
return schema
|
|
113
|
+
|
|
114
|
+
raise TypeError(
|
|
115
|
+
f"coord/openapi.py: no JSON Schema mapping for Python type {tp!r} — "
|
|
116
|
+
"add one to json_schema_for()."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dataclass_schema(cls: type, components: dict[str, Any]) -> dict[str, Any]:
|
|
121
|
+
"""Register *cls* (a dataclass) into ``components`` and return a ``$ref``.
|
|
122
|
+
|
|
123
|
+
Idempotent — a dataclass already registered is not re-walked, so cyclic /
|
|
124
|
+
repeated references (e.g. several endpoints sharing ``Assignment``) are
|
|
125
|
+
safe.
|
|
126
|
+
"""
|
|
127
|
+
name = cls.__name__
|
|
128
|
+
ref = {"$ref": f"#/components/schemas/{name}"}
|
|
129
|
+
if name in components:
|
|
130
|
+
return ref
|
|
131
|
+
|
|
132
|
+
# Reserve the slot before recursing so a self-referential dataclass
|
|
133
|
+
# doesn't recurse forever.
|
|
134
|
+
components[name] = {}
|
|
135
|
+
hints = typing.get_type_hints(cls)
|
|
136
|
+
properties: dict[str, Any] = {}
|
|
137
|
+
required: list[str] = []
|
|
138
|
+
for f in dataclasses.fields(cls):
|
|
139
|
+
properties[f.name] = json_schema_for(hints[f.name], components)
|
|
140
|
+
if f.default is dataclasses.MISSING and f.default_factory is dataclasses.MISSING: # type: ignore[misc]
|
|
141
|
+
required.append(f.name)
|
|
142
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
143
|
+
if required:
|
|
144
|
+
schema["required"] = required
|
|
145
|
+
components[name] = schema
|
|
146
|
+
return ref
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── SQLite table → JSON Schema (the /board wire schema IS the DDL) ──────────
|
|
150
|
+
|
|
151
|
+
# Per-column overrides for JSON-encoded TEXT columns (decoded to a native
|
|
152
|
+
# object/array before hitting the wire — see coord/dao.py:_JSON_COLUMNS).
|
|
153
|
+
# Anything JSON-decoded but not listed here still gets a properties entry,
|
|
154
|
+
# just typed as "any" ({}) rather than a precise array/object shape.
|
|
155
|
+
_JSON_COLUMN_SHAPES: dict[tuple[str, str], dict[str, Any]] = {
|
|
156
|
+
("assignments", "files_allowed"): {"type": "array", "items": {"type": "string"}},
|
|
157
|
+
("assignments", "files_forbidden"): {"type": "array", "items": {"type": "string"}},
|
|
158
|
+
("assignments", "required_gates"): {"type": "array", "items": {"type": "string"}},
|
|
159
|
+
("assignments", "smoke_tests"): {"type": "array", "items": {"type": "string"}},
|
|
160
|
+
("assignments", "plan"): {"type": "object"},
|
|
161
|
+
("assignments", "test_plan"): {"type": "object"},
|
|
162
|
+
("proposals", "files_likely"): {"type": "array", "items": {"type": "string"}},
|
|
163
|
+
("proposals", "required_gates"): {"type": "array", "items": {"type": "string"}},
|
|
164
|
+
("merge_queue", "required_gates"): {"type": "array", "items": {"type": "string"}},
|
|
165
|
+
("issues", "labels"): {"type": "array", "items": {"type": "string"}},
|
|
166
|
+
("machines", "capabilities"): {"type": "array", "items": {"type": "string"}},
|
|
167
|
+
("machines", "repos"): {"type": "array", "items": {"type": "string"}},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_SQLITE_AFFINITY_TO_SCHEMA: dict[str, dict[str, Any]] = {
|
|
171
|
+
"INTEGER": {"type": "integer"},
|
|
172
|
+
"REAL": {"type": "number"},
|
|
173
|
+
"TEXT": {"type": "string"},
|
|
174
|
+
"BLOB": {"type": "string"},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def sqlite_table_schema(
|
|
179
|
+
conn: sqlite3.Connection,
|
|
180
|
+
table: str,
|
|
181
|
+
*,
|
|
182
|
+
drop: frozenset[str] = frozenset(),
|
|
183
|
+
json_columns: frozenset[str] = frozenset(),
|
|
184
|
+
) -> dict[str, Any]:
|
|
185
|
+
"""Build an ``object`` JSON Schema straight from ``PRAGMA table_info``.
|
|
186
|
+
|
|
187
|
+
*drop* — columns the projection omits (e.g. ``assignments.briefing``,
|
|
188
|
+
see ``coord/dao.py:_DROP_COLUMNS``).
|
|
189
|
+
*json_columns* — columns decoded from a JSON-TEXT column to a native
|
|
190
|
+
value (see ``coord/dao.py:_JSON_COLUMNS``); typed via
|
|
191
|
+
:data:`_JSON_COLUMN_SHAPES` when known, else left as "any".
|
|
192
|
+
|
|
193
|
+
Every column is nullable unless SQLite's ``PRAGMA table_info`` reports
|
|
194
|
+
``notnull=1`` — SQLite's own affinity typing is loose, and several
|
|
195
|
+
columns (e.g. ``finished_at`` while an assignment is running) are
|
|
196
|
+
legitimately absent until a later write.
|
|
197
|
+
"""
|
|
198
|
+
properties: dict[str, Any] = {}
|
|
199
|
+
required: list[str] = []
|
|
200
|
+
for _cid, name, decl_type, notnull, _dflt, _pk in conn.execute(
|
|
201
|
+
f"PRAGMA table_info({table})" # noqa: S608 — table name is a literal, not user input
|
|
202
|
+
):
|
|
203
|
+
if name in drop:
|
|
204
|
+
continue
|
|
205
|
+
if name in json_columns:
|
|
206
|
+
schema = dict(_JSON_COLUMN_SHAPES.get((table, name), {}))
|
|
207
|
+
else:
|
|
208
|
+
affinity = (decl_type or "TEXT").split("(")[0].upper()
|
|
209
|
+
schema = dict(_SQLITE_AFFINITY_TO_SCHEMA.get(affinity, {"type": "string"}))
|
|
210
|
+
if notnull:
|
|
211
|
+
required.append(name)
|
|
212
|
+
elif schema:
|
|
213
|
+
schema["nullable"] = True
|
|
214
|
+
properties[name] = schema
|
|
215
|
+
out: dict[str, Any] = {"type": "object", "properties": properties}
|
|
216
|
+
if required:
|
|
217
|
+
out["required"] = required
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── spec assembly ─────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def build_spec(
|
|
224
|
+
*,
|
|
225
|
+
title: str,
|
|
226
|
+
version: str,
|
|
227
|
+
description: str = "",
|
|
228
|
+
paths: dict[str, Any],
|
|
229
|
+
components: dict[str, Any] | None = None,
|
|
230
|
+
servers: list[dict[str, str]] | None = None,
|
|
231
|
+
) -> dict[str, Any]:
|
|
232
|
+
"""Assemble a minimal OpenAPI 3.0.3 document."""
|
|
233
|
+
spec: dict[str, Any] = {
|
|
234
|
+
"openapi": OPENAPI_VERSION,
|
|
235
|
+
"info": {"title": title, "version": version, "description": description},
|
|
236
|
+
"paths": paths,
|
|
237
|
+
}
|
|
238
|
+
if servers:
|
|
239
|
+
spec["servers"] = servers
|
|
240
|
+
if components:
|
|
241
|
+
spec["components"] = {"schemas": components}
|
|
242
|
+
return spec
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def openapi_and_docs_routes(
|
|
246
|
+
spec: dict[str, Any],
|
|
247
|
+
*,
|
|
248
|
+
openapi_path: str = "/openapi.json",
|
|
249
|
+
docs_path: str = "/docs",
|
|
250
|
+
) -> list[BaseRoute]:
|
|
251
|
+
"""Return the ``[GET /openapi.json, GET /docs]`` routes every app appends.
|
|
252
|
+
|
|
253
|
+
``/docs`` is a small Swagger UI page (CDN-hosted ``swagger-ui-dist``,
|
|
254
|
+
pinned version) pointed at ``/openapi.json`` — no new Python dependency,
|
|
255
|
+
consistent with how e.g. FastAPI's default docs page works.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
async def openapi_json(_request: Request) -> JSONResponse:
|
|
259
|
+
return JSONResponse(spec)
|
|
260
|
+
|
|
261
|
+
title = spec.get("info", {}).get("title", "API")
|
|
262
|
+
docs_html = f"""<!DOCTYPE html>
|
|
263
|
+
<html>
|
|
264
|
+
<head>
|
|
265
|
+
<title>{title} — docs</title>
|
|
266
|
+
<meta charset="utf-8" />
|
|
267
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
|
|
268
|
+
</head>
|
|
269
|
+
<body>
|
|
270
|
+
<div id="swagger-ui"></div>
|
|
271
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
272
|
+
<script>
|
|
273
|
+
window.onload = () => {{
|
|
274
|
+
window.ui = SwaggerUIBundle({{
|
|
275
|
+
url: "{openapi_path}",
|
|
276
|
+
dom_id: "#swagger-ui",
|
|
277
|
+
}});
|
|
278
|
+
}};
|
|
279
|
+
</script>
|
|
280
|
+
</body>
|
|
281
|
+
</html>"""
|
|
282
|
+
|
|
283
|
+
async def docs(_request: Request) -> HTMLResponse:
|
|
284
|
+
return HTMLResponse(docs_html)
|
|
285
|
+
|
|
286
|
+
return [
|
|
287
|
+
Route(openapi_path, openapi_json, methods=["GET"], include_in_schema=False),
|
|
288
|
+
Route(docs_path, docs, methods=["GET"], include_in_schema=False),
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── validate a JSON value against a generated schema (#748 tie-in) ─────────
|
|
293
|
+
|
|
294
|
+
_JSON_TYPE_CHECK: dict[str, type | tuple[type, ...]] = {
|
|
295
|
+
"string": str,
|
|
296
|
+
# bool is an int subclass in Python — exclude it from the integer/number
|
|
297
|
+
# check so a JSON `true` doesn't pass as a number.
|
|
298
|
+
"integer": (int,),
|
|
299
|
+
"number": (int, float),
|
|
300
|
+
"boolean": bool,
|
|
301
|
+
"array": list,
|
|
302
|
+
"object": dict,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def validate_json_schema(
|
|
307
|
+
instance: Any, schema: dict[str, Any], components: dict[str, Any], *, path: str = "$"
|
|
308
|
+
) -> list[str]:
|
|
309
|
+
"""Validate *instance* against a schema produced by this module.
|
|
310
|
+
|
|
311
|
+
Not a general-purpose JSON Schema validator (no external dependency is
|
|
312
|
+
pulled in for it) — just enough of the dialect this module itself emits
|
|
313
|
+
(``type``, ``nullable``, ``properties``/``required``, ``items``,
|
|
314
|
+
``additionalProperties``, ``$ref``, ``anyOf``) to prove the #757 specs
|
|
315
|
+
actually describe a real payload, e.g. the #748 golden ``/board``
|
|
316
|
+
fixture. Returns a list of human-readable error strings; empty means
|
|
317
|
+
valid.
|
|
318
|
+
"""
|
|
319
|
+
if "$ref" in schema:
|
|
320
|
+
name = schema["$ref"].rsplit("/", 1)[-1]
|
|
321
|
+
target = components.get(name)
|
|
322
|
+
if target is None:
|
|
323
|
+
return [f"{path}: unresolvable $ref {schema['$ref']!r}"]
|
|
324
|
+
return validate_json_schema(instance, target, components, path=path)
|
|
325
|
+
|
|
326
|
+
if "anyOf" in schema:
|
|
327
|
+
errors_per_branch = [
|
|
328
|
+
validate_json_schema(instance, branch, components, path=path)
|
|
329
|
+
for branch in schema["anyOf"]
|
|
330
|
+
]
|
|
331
|
+
if any(not errs for errs in errors_per_branch):
|
|
332
|
+
return []
|
|
333
|
+
return [f"{path}: matched none of {len(schema['anyOf'])} anyOf branches"]
|
|
334
|
+
|
|
335
|
+
if instance is None:
|
|
336
|
+
if schema.get("nullable") or schema.get("type") == "null" or not schema:
|
|
337
|
+
return []
|
|
338
|
+
return [f"{path}: null but schema is not nullable ({schema.get('type')!r})"]
|
|
339
|
+
|
|
340
|
+
json_type = schema.get("type")
|
|
341
|
+
if json_type is None:
|
|
342
|
+
return [] # untyped ("any") schema — nothing to check
|
|
343
|
+
|
|
344
|
+
py_type = _JSON_TYPE_CHECK.get(json_type)
|
|
345
|
+
if py_type is not None and not isinstance(instance, py_type):
|
|
346
|
+
return [f"{path}: expected {json_type}, got {type(instance).__name__}"]
|
|
347
|
+
|
|
348
|
+
errors: list[str] = []
|
|
349
|
+
if json_type == "object" and isinstance(instance, dict):
|
|
350
|
+
properties = schema.get("properties", {})
|
|
351
|
+
for req in schema.get("required", ()):
|
|
352
|
+
if req not in instance:
|
|
353
|
+
errors.append(f"{path}: missing required property {req!r}")
|
|
354
|
+
for key, value in instance.items():
|
|
355
|
+
prop_schema = properties.get(key)
|
|
356
|
+
if prop_schema is not None:
|
|
357
|
+
errors.extend(
|
|
358
|
+
validate_json_schema(value, prop_schema, components, path=f"{path}.{key}")
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
addl = schema.get("additionalProperties")
|
|
362
|
+
if isinstance(addl, dict):
|
|
363
|
+
errors.extend(
|
|
364
|
+
validate_json_schema(value, addl, components, path=f"{path}.{key}")
|
|
365
|
+
)
|
|
366
|
+
elif json_type == "array" and isinstance(instance, list):
|
|
367
|
+
items_schema = schema.get("items")
|
|
368
|
+
if items_schema:
|
|
369
|
+
for i, item in enumerate(instance):
|
|
370
|
+
errors.extend(
|
|
371
|
+
validate_json_schema(item, items_schema, components, path=f"{path}[{i}]")
|
|
372
|
+
)
|
|
373
|
+
return errors
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── route-inventory drift check ──────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
def declared_routes(routes: list[BaseRoute]) -> set[tuple[str, str]]:
|
|
379
|
+
"""``{(METHOD, path), ...}`` for every plain ``Route`` in *routes*.
|
|
380
|
+
|
|
381
|
+
Skips non-``Route`` entries (``Mount``/``StaticFiles`` — not a single
|
|
382
|
+
documentable JSON endpoint) and anything with ``include_in_schema=False``
|
|
383
|
+
(the meta ``/openapi.json`` and ``/docs`` routes themselves), and drops
|
|
384
|
+
the implicit ``HEAD``/``OPTIONS`` methods Starlette adds to every
|
|
385
|
+
``GET``/any route.
|
|
386
|
+
"""
|
|
387
|
+
out: set[tuple[str, str]] = set()
|
|
388
|
+
for route in routes:
|
|
389
|
+
if not isinstance(route, Route) or not route.include_in_schema:
|
|
390
|
+
continue
|
|
391
|
+
for method in route.methods or ["GET"]:
|
|
392
|
+
if method in ("HEAD", "OPTIONS"):
|
|
393
|
+
continue
|
|
394
|
+
out.add((method, route.path))
|
|
395
|
+
return out
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def spec_routes(spec: dict[str, Any]) -> set[tuple[str, str]]:
|
|
399
|
+
"""``{(METHOD, path), ...}`` declared in an OpenAPI document's ``paths``."""
|
|
400
|
+
out: set[tuple[str, str]] = set()
|
|
401
|
+
for path, methods in spec.get("paths", {}).items():
|
|
402
|
+
for method in methods:
|
|
403
|
+
out.add((method.upper(), path))
|
|
404
|
+
return out
|
coord/overlap_fence.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""#1720: dispatch-time file-overlap fence, derived from live branch
|
|
2
|
+
footprints instead of the prompt-only heuristic in ``coord/brain.py``.
|
|
3
|
+
|
|
4
|
+
``coord/brain.py``'s ``SYSTEM_PROMPT`` tells the planning LLM "if two issues
|
|
5
|
+
would touch overlapping files in the same repo, do NOT assign them
|
|
6
|
+
simultaneously" — but that only applies to ``coord plan``, and it works by
|
|
7
|
+
GUESSING an issue's future footprint from its body text before any code
|
|
8
|
+
exists. ``coord assign``/``coord drive`` (and the auto-loop's fix dispatch)
|
|
9
|
+
have nothing: the only thing standing between two concurrent workers and a
|
|
10
|
+
collision has been an operator hand-writing a `coord context add --pin` note.
|
|
11
|
+
|
|
12
|
+
This module makes the rule factual and puts it on the paths that actually
|
|
13
|
+
dispatch. At dispatch time each *currently running* work-like assignment in
|
|
14
|
+
the target repo already has a branch — its real footprint is one GitHub
|
|
15
|
+
compare call away (``git diff --name-only`` semantics via the compare API,
|
|
16
|
+
no local checkout required — see :func:`coord.github_ops.get_compare_files`).
|
|
17
|
+
That is measured, not inferred, and — critically — recomputed on every call,
|
|
18
|
+
never pinned: a hand-written pin note keeps warning about files long after
|
|
19
|
+
the other work merged and freed them; this has no such staleness because it
|
|
20
|
+
is thrown away and rebuilt from live board state on every dispatch.
|
|
21
|
+
|
|
22
|
+
Advisory only. This module never raises to its caller and never blocks a
|
|
23
|
+
dispatch — see :func:`compute_overlap_fence`'s docstring.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Callable
|
|
29
|
+
|
|
30
|
+
from coord.models import WORK_LIKE_TYPES, Assignment, Board
|
|
31
|
+
|
|
32
|
+
# Type of the optional fetcher callers may inject (tests; also the seam a
|
|
33
|
+
# future local-checkout fallback could use). Returns the changed file paths
|
|
34
|
+
# for `base...head`, or None if the diff couldn't be computed.
|
|
35
|
+
DiffFilesFetcher = Callable[[str, str, str], "list[str] | None"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _default_fetcher(repo_github: str, base: str, head: str) -> list[str] | None:
|
|
39
|
+
from coord import github_ops # noqa: PLC0415
|
|
40
|
+
|
|
41
|
+
return github_ops.get_compare_files(repo_github, base, head)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _running_work_like(
|
|
45
|
+
board: Board, repo_name: str, *, exclude_issue_number: int | None,
|
|
46
|
+
) -> list[Assignment]:
|
|
47
|
+
"""Running WORK_LIKE_TYPES assignments in *repo_name* with a pushed
|
|
48
|
+
branch, excluding *exclude_issue_number* (the issue about to be
|
|
49
|
+
dispatched — relevant on a redispatch of an issue that already has a
|
|
50
|
+
running row, so it never fences against itself)."""
|
|
51
|
+
return [
|
|
52
|
+
a
|
|
53
|
+
for a in board.active
|
|
54
|
+
if a.status == "running"
|
|
55
|
+
and a.repo_name == repo_name
|
|
56
|
+
and a.type in WORK_LIKE_TYPES
|
|
57
|
+
and a.branch
|
|
58
|
+
and a.issue_number != exclude_issue_number
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_overlap_fence(
|
|
63
|
+
repo_name: str,
|
|
64
|
+
repo_github: str,
|
|
65
|
+
base_branch: str,
|
|
66
|
+
*,
|
|
67
|
+
exclude_issue_number: int | None = None,
|
|
68
|
+
board: Board | None = None,
|
|
69
|
+
diff_files_fetcher: DiffFilesFetcher | None = None,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Return a markdown fence block naming files other *running* work-like
|
|
72
|
+
assignments in *repo_name* are touching, or ``""`` when there is nothing
|
|
73
|
+
to report (no running assignments, none with a pushed branch, or every
|
|
74
|
+
branch diff failed) — the caller prepends this with no special-casing,
|
|
75
|
+
same as the #603 issue-context digest.
|
|
76
|
+
|
|
77
|
+
Advisory, not a gate: this NEVER refuses or blocks — it only informs the
|
|
78
|
+
new worker so it can stop and report a genuine collision itself, mirroring
|
|
79
|
+
the wording an operator already hand-writes via
|
|
80
|
+
``coord context add --pin`` (see docs/OPERATING_GOTCHAS.md).
|
|
81
|
+
|
|
82
|
+
Fails open at every layer, by construction:
|
|
83
|
+
- the whole board read is wrapped in `except Exception` — an unreachable
|
|
84
|
+
daemon/board must not block a dispatch;
|
|
85
|
+
- each per-assignment diff fetch is wrapped individually — one
|
|
86
|
+
unreadable/deleted/force-pushed branch is skipped, not fatal to the
|
|
87
|
+
others (fulfils "a branch whose diff cannot be computed is skipped,
|
|
88
|
+
dispatch proceeds");
|
|
89
|
+
- a branch with no pushed commits yet resolves to an empty (or ``None``)
|
|
90
|
+
file list either way and contributes nothing, never an error.
|
|
91
|
+
|
|
92
|
+
Never persisted — recomputed from live board state on every call, so
|
|
93
|
+
redispatching the same issue twice can't stack fences (there is nothing
|
|
94
|
+
to stack; the caller just gets a fresh string each time).
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
if board is None:
|
|
98
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
99
|
+
|
|
100
|
+
board = read_board()
|
|
101
|
+
running = _running_work_like(
|
|
102
|
+
board, repo_name, exclude_issue_number=exclude_issue_number
|
|
103
|
+
)
|
|
104
|
+
except Exception: # noqa: BLE001 — advisory only; never block a dispatch
|
|
105
|
+
return ""
|
|
106
|
+
if not running:
|
|
107
|
+
return ""
|
|
108
|
+
|
|
109
|
+
fetcher = diff_files_fetcher or _default_fetcher
|
|
110
|
+
lines: list[str] = []
|
|
111
|
+
for a in running:
|
|
112
|
+
try:
|
|
113
|
+
files = fetcher(repo_github, base_branch, a.branch) # type: ignore[arg-type]
|
|
114
|
+
except Exception: # noqa: BLE001 — one bad branch must not sink the rest
|
|
115
|
+
files = None
|
|
116
|
+
if not files:
|
|
117
|
+
continue
|
|
118
|
+
file_list = ", ".join(f"`{f}`" for f in sorted(files))
|
|
119
|
+
lines.append(f"- Issue #{a.issue_number} (branch `{a.branch}`): {file_list}")
|
|
120
|
+
|
|
121
|
+
if not lines:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
"## ⚠️ Live file-overlap fence (#1720)\n"
|
|
126
|
+
"Other work is RUNNING RIGHT NOW in this repo and touching the files "
|
|
127
|
+
"listed below. This is informational, not a rule against parallel "
|
|
128
|
+
"work — but if your own changes would touch the SAME file(s), STOP "
|
|
129
|
+
"and report the conflict instead of editing around it or racing to "
|
|
130
|
+
"commit first:\n"
|
|
131
|
+
+ "\n".join(lines)
|
|
132
|
+
+ "\n"
|
|
133
|
+
)
|