coda-node 0.1.0__tar.gz → 0.1.1__tar.gz

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.
Files changed (81) hide show
  1. coda_node-0.1.1/.agents/skills/update-changelog/SKILL.md +22 -0
  2. {coda_node-0.1.0 → coda_node-0.1.1}/.github/workflows/ci.yml +1 -0
  3. {coda_node-0.1.0 → coda_node-0.1.1}/.github/workflows/publish.yml +8 -0
  4. coda_node-0.1.1/.github/workflows/release.yml +118 -0
  5. coda_node-0.1.1/CHANGELOG.md +19 -0
  6. {coda_node-0.1.0 → coda_node-0.1.1}/PKG-INFO +2 -2
  7. {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/CONNECT_PROTOCOL.md +11 -10
  8. {coda_node-0.1.0 → coda_node-0.1.1}/pyproject.toml +1 -1
  9. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/__init__.py +1 -1
  10. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/errors.py +5 -0
  11. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/app.py +2 -11
  12. coda_node-0.1.1/src/coda_node/server/device_topology.py +20 -0
  13. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/heartbeat.py +28 -1
  14. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/service.py +42 -6
  15. coda_node-0.1.1/tests/test_device_topology.py +25 -0
  16. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_heartbeat.py +38 -1
  17. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_main.py +0 -28
  18. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_node.py +76 -6
  19. {coda_node-0.1.0 → coda_node-0.1.1}/uv.lock +122 -99
  20. {coda_node-0.1.0 → coda_node-0.1.1}/.agents/skills/read-docs/SKILL.md +0 -0
  21. {coda_node-0.1.0 → coda_node-0.1.1}/.agents/skills/update-docs/SKILL.md +0 -0
  22. {coda_node-0.1.0 → coda_node-0.1.1}/.gitignore +0 -0
  23. {coda_node-0.1.0 → coda_node-0.1.1}/.pre-commit-config.yaml +0 -0
  24. {coda_node-0.1.0 → coda_node-0.1.1}/.python-version +0 -0
  25. {coda_node-0.1.0 → coda_node-0.1.1}/Dockerfile.e2e +0 -0
  26. {coda_node-0.1.0 → coda_node-0.1.1}/LICENSE +0 -0
  27. {coda_node-0.1.0 → coda_node-0.1.1}/README.md +0 -0
  28. {coda_node-0.1.0 → coda_node-0.1.1}/docs/INDEX.md +0 -0
  29. {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/INDEX.md +0 -0
  30. {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/KEYPAIR_LIFECYCLE.md +0 -0
  31. {coda_node-0.1.0 → coda_node-0.1.1}/docs/auth/SIGNING.md +0 -0
  32. {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/ENVIRONMENT_VARIABLES.md +0 -0
  33. {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/INDEX.md +0 -0
  34. {coda_node-0.1.0 → coda_node-0.1.1}/docs/configuration/SETTINGS_REFERENCE.md +0 -0
  35. {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/DEVICE_CONFIG.md +0 -0
  36. {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/FRAMEWORK_PROTOCOL.md +0 -0
  37. {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/INDEX.md +0 -0
  38. {coda_node-0.1.0 → coda_node-0.1.1}/docs/frameworks/REGISTRY.md +0 -0
  39. {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/CONSUMER.md +0 -0
  40. {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/EXECUTOR.md +0 -0
  41. {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/INDEX.md +0 -0
  42. {coda_node-0.1.0 → coda_node-0.1.1}/docs/jobs/IR_SCHEMA.md +0 -0
  43. {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/CREDENTIAL_PERSISTENCE.md +0 -0
  44. {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/INDEX.md +0 -0
  45. {coda_node-0.1.0 → coda_node-0.1.1}/docs/node/TOKEN_LIFECYCLE.md +0 -0
  46. {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/CLI.md +0 -0
  47. {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/ERROR_HANDLING.md +0 -0
  48. {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/GRACEFUL_SHUTDOWN.md +0 -0
  49. {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/HEALTH_ENDPOINTS.md +0 -0
  50. {coda_node-0.1.0 → coda_node-0.1.1}/docs/operations/INDEX.md +0 -0
  51. {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/CLOUD_INFRASTRUCTURE.md +0 -0
  52. {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/HEALTH_MONITORING.md +0 -0
  53. {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/INDEX.md +0 -0
  54. {coda_node-0.1.0 → coda_node-0.1.1}/docs/vpn/TUNNEL_LIFECYCLE.md +0 -0
  55. {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/DELIVERY.md +0 -0
  56. {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/INDEX.md +0 -0
  57. {coda_node-0.1.0 → coda_node-0.1.1}/docs/webhooks/PAYLOAD_FORMAT.md +0 -0
  58. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/py.typed +0 -0
  59. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/__init__.py +0 -0
  60. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/auth.py +0 -0
  61. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/cli.py +0 -0
  62. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/config.py +0 -0
  63. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/consumer.py +0 -0
  64. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/daemon.py +0 -0
  65. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/executor.py +0 -0
  66. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/ir.py +0 -0
  67. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/qasm.py +0 -0
  68. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/server/webhook.py +0 -0
  69. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/__init__.py +0 -0
  70. {coda_node-0.1.0 → coda_node-0.1.1}/src/coda_node/vpn/guard.py +0 -0
  71. {coda_node-0.1.0 → coda_node-0.1.1}/tests/conftest.py +0 -0
  72. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_auth.py +0 -0
  73. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_cli.py +0 -0
  74. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_config.py +0 -0
  75. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_consumer.py +0 -0
  76. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_daemon.py +0 -0
  77. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_frameworks.py +0 -0
  78. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_ir.py +0 -0
  79. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_qasm.py +0 -0
  80. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_vpn.py +0 -0
  81. {coda_node-0.1.0 → coda_node-0.1.1}/tests/test_webhook.py +0 -0
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: update-changelog
3
+ description: Add an entry to CHANGELOG.md for a new version.
4
+ disable-model-invocation: true
5
+ argument-hint: [version]
6
+ ---
7
+
8
+ # Update Changelog
9
+
10
+ Add a new entry to `CHANGELOG.md` for the given version.
11
+
12
+ ## Steps
13
+
14
+ 1. Read `CHANGELOG.md`.
15
+ 2. Review recent commits since the last tagged release with `git log`.
16
+ 3. Add a new `## [x.y.z] - YYYY-MM-DD` section below the header, above the previous entry.
17
+ 4. Summarise changes concisely under `### Added`, `### Changed`, `### Fixed`, or `### Removed` subsections as appropriate.
18
+ 5. Follow the existing style — short bullet points, no links.
19
+
20
+ ## Arguments
21
+
22
+ `/update-changelog 0.3.0` — the version string to use in the heading. If omitted, ask the user.
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  pull_request:
6
6
  workflow_dispatch:
7
+ workflow_call:
7
8
 
8
9
  jobs:
9
10
  test:
@@ -1,6 +1,12 @@
1
1
  name: Publish to PyPI
2
2
 
3
3
  on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ tag:
7
+ description: "Existing release tag to publish (e.g. v0.2.0)"
8
+ required: true
9
+ type: string
4
10
  release:
5
11
  types:
6
12
  - created
@@ -15,6 +21,8 @@ jobs:
15
21
  steps:
16
22
  - name: Checkout repository
17
23
  uses: actions/checkout@v4
24
+ with:
25
+ ref: refs/tags/${{ inputs.tag || github.event.release.tag_name }}
18
26
 
19
27
  - name: Set up Python
20
28
  uses: actions/setup-python@v6
@@ -0,0 +1,118 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ version:
7
+ description: "Version to release (e.g. 0.2.0)"
8
+ required: true
9
+ type: string
10
+
11
+ concurrency:
12
+ group: release
13
+ cancel-in-progress: false
14
+
15
+ env:
16
+ VERSION: ${{ inputs.version }}
17
+
18
+ jobs:
19
+ validate:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ with:
24
+ fetch-depth: 0
25
+
26
+ - name: Require main branch
27
+ run: |
28
+ if [ "$GITHUB_REF" != "refs/heads/main" ]; then
29
+ echo "::error::Releases must be run from main"
30
+ exit 1
31
+ fi
32
+
33
+ - name: Validate semver format
34
+ run: |
35
+ if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
36
+ echo "::error::Version '$VERSION' is not valid semver (expected x.y.z)"
37
+ exit 1
38
+ fi
39
+
40
+ - name: Check tag does not already exist
41
+ run: |
42
+ if git show-ref --verify --quiet "refs/tags/v${VERSION}"; then
43
+ echo "::error::Tag v${VERSION} already exists"
44
+ exit 1
45
+ fi
46
+
47
+ - name: Check CHANGELOG has entry for version
48
+ run: |
49
+ if ! awk -v heading="## [$VERSION]" '
50
+ index($0, heading) == 1 {found=1}
51
+ END {exit !found}
52
+ ' CHANGELOG.md; then
53
+ echo "::error::CHANGELOG.md has no entry for version $VERSION"
54
+ exit 1
55
+ fi
56
+
57
+ ci:
58
+ needs: validate
59
+ uses: ./.github/workflows/ci.yml
60
+
61
+ release:
62
+ needs: ci
63
+ runs-on: ubuntu-latest
64
+ permissions:
65
+ contents: write
66
+ actions: write
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+ with:
70
+ fetch-depth: 0
71
+ ref: ${{ github.sha }}
72
+
73
+ - name: Configure git
74
+ run: |
75
+ git config user.name "github-actions[bot]"
76
+ git config user.email "github-actions[bot]@users.noreply.github.com"
77
+
78
+ - uses: astral-sh/setup-uv@v5
79
+
80
+ - name: Bump version in pyproject.toml
81
+ run: sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
82
+
83
+ - name: Bump version in __init__.py
84
+ run: sed -i "s/^__version__ = \".*\"/__version__ = \"${VERSION}\"/" src/coda_node/__init__.py
85
+
86
+ - name: Refresh lockfile
87
+ run: uv lock
88
+
89
+ - name: Commit and tag
90
+ run: |
91
+ git add pyproject.toml src/coda_node/__init__.py uv.lock
92
+ if ! git diff --cached --quiet; then
93
+ git commit -m "release: v${VERSION}"
94
+ fi
95
+ git tag "v${VERSION}"
96
+ git push --atomic origin HEAD:refs/heads/main "refs/tags/v${VERSION}"
97
+
98
+ - name: Extract release notes from CHANGELOG
99
+ run: |
100
+ awk -v heading="## [$VERSION]" '
101
+ index($0, heading) == 1 {found=1; next}
102
+ found && /^## \[/ {exit}
103
+ found
104
+ ' CHANGELOG.md > release-notes.md
105
+
106
+ - name: Create GitHub Release
107
+ env:
108
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
109
+ run: |
110
+ gh release create "v${VERSION}" \
111
+ --verify-tag \
112
+ --title "v${VERSION}" \
113
+ --notes-file release-notes.md
114
+
115
+ - name: Start PyPI publishing
116
+ env:
117
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118
+ run: gh workflow run publish.yml --ref main --field "tag=v${VERSION}"
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.1.1] - 2026-04-06
9
+
10
+ ### Added
11
+
12
+ - Connectivity reporting in heartbeat and connect payloads.
13
+ - `HeartbeatRejectedError` when the platform rejects a heartbeat.
14
+
15
+ ## [0.1.0] - 2026-04-01
16
+
17
+ ### Added
18
+
19
+ - Initial release: JWT auth, VPN management, Redis job consumer, webhook delivery, heartbeat, IR/QASM support, `JobExecutor` protocol, daemon mode, and CLI.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: coda-node
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Minimal server runtime for connecting execution backends to Coda.
5
5
  Author: Conductor Quantum
6
6
  License: MIT License
@@ -25,31 +25,32 @@ The cloud uses `token.split(".").length === 3` to detect JWTs.
25
25
 
26
26
  ```json
27
27
  {
28
- "machine_fingerprint": "hostname-macaddr"
28
+ "machine_fingerprint": "hostname-macaddr",
29
+ "connectivity": [[1, 0], [2, 1]]
29
30
  }
30
31
  ```
31
32
 
32
33
  | Field | Type | Required | Description |
33
34
  |---|---|---|---|
34
35
  | `machine_fingerprint` | `string` | Yes | Stable host identifier (`{hostname}-{mac_address}`). Generated by the node if not set. Stored on first run and verified on subsequent reconnects. |
36
+ | `connectivity` | `number[][]` \| omitted | No | Live edge list from the executor device spec (same shape as heartbeat). When present, the cloud validates every endpoint against the registered `qpu_devices.qubits` / bootstrap token `num_qubits` **before** issuing the bundle, so a topology mismatch fails at connect time. Omitted when the executor has no device (e.g. `NoopExecutor`). |
35
37
 
36
38
  ### Node-Side Request Construction
37
39
 
38
- **Node** (`fetch_node_bundle` in `vpn/service.py`):
40
+ **Node** (`fetch_node_bundle` / `fetch_reconnect_bundle` in `vpn/service.py`):
39
41
 
40
- ```python
41
- payload = {"machine_fingerprint": settings.node_machine_fingerprint}
42
- auth_header = f"Bearer {settings.node_token}"
43
- ```
44
-
45
- **Reconnect** (`fetch_reconnect_bundle` in `vpn/service.py`):
42
+ After `connect_settings` loads the executor via `_connectivity_payload_for_connect()`,
43
+ the JSON body includes `machine_fingerprint` and, when available, `connectivity`.
46
44
 
47
45
  ```python
48
46
  payload = {"machine_fingerprint": settings.node_machine_fingerprint}
49
- token = sign_token(settings.qpu_id, settings.jwt_private_key, key_id=settings.jwt_key_id)
50
- auth_header = f"Bearer {token}"
47
+ if connectivity is not None:
48
+ payload["connectivity"] = connectivity
49
+ auth_header = f"Bearer {settings.node_token}" # or JWT for reconnect
51
50
  ```
52
51
 
52
+ **Reconnect** uses the same payload shape with a JWT `Authorization` header.
53
+
53
54
  Both call `_post_connect()` with exponential-backoff retry on 5xx and
54
55
  transport errors (configurable via `CODA_NODE_CONNECT_RETRIES`,
55
56
  default 3).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "coda-node"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Minimal server runtime for connecting execution backends to Coda."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -14,5 +14,5 @@ Typical usage::
14
14
  from coda_node.errors import CodaError
15
15
  from coda_node.server import app, create_app
16
16
 
17
- __version__ = "0.1.0"
17
+ __version__ = "0.1.1"
18
18
  __all__ = ["CodaError", "__version__", "app", "create_app"]
@@ -10,6 +10,7 @@ __all__ = [
10
10
  "CodaError",
11
11
  "ConfigError",
12
12
  "ExecutorError",
13
+ "HeartbeatRejectedError",
13
14
  "NodeError",
14
15
  "VPNError",
15
16
  "WebhookError",
@@ -36,6 +37,10 @@ class NodeError(CodaError):
36
37
  """Node provisioning or reconnect failure."""
37
38
 
38
39
 
40
+ class HeartbeatRejectedError(CodaError):
41
+ """Cloud returned a non-success response to a QPU heartbeat POST."""
42
+
43
+
39
44
  class ExecutorError(CodaError):
40
45
  """Executor loading or job execution failure."""
41
46
 
@@ -19,6 +19,7 @@ from fastapi.responses import JSONResponse
19
19
 
20
20
  from coda_node.server.config import Settings
21
21
  from coda_node.server.consumer import RedisConsumer
22
+ from coda_node.server.device_topology import resolve_connectivity_from_device_spec
22
23
  from coda_node.server.executor import JobExecutor, load_executor
23
24
  from coda_node.server.heartbeat import HeartbeatClient
24
25
  from coda_node.server.webhook import WebhookClient
@@ -32,16 +33,6 @@ from coda_node.vpn import (
32
33
  logger = logging.getLogger(__name__)
33
34
 
34
35
 
35
- def _resolve_connectivity(device_spec: object | None) -> list[list[int]] | None:
36
- """Extract qubit connectivity from a device spec, preferring directed edges."""
37
- if device_spec is None:
38
- return None
39
- directed = getattr(device_spec, "directed_edges", None)
40
- if directed:
41
- return [list(e) for e in directed]
42
- return [list(e) for e in device_spec.logical_edges] # type: ignore[attr-defined]
43
-
44
-
45
36
  async def _on_vpn_state_change(state: ServiceState) -> None:
46
37
  """Log VPN state transitions at WARNING level."""
47
38
  logger.warning("VPN state changed: %s", state.value)
@@ -93,7 +84,7 @@ def create_app(executor: JobExecutor | None = None) -> FastAPI:
93
84
  runner = executor or load_executor(settings)
94
85
 
95
86
  device_spec = getattr(runner, "device", None)
96
- connectivity = _resolve_connectivity(device_spec)
87
+ connectivity = resolve_connectivity_from_device_spec(device_spec)
97
88
 
98
89
  webhook = WebhookClient(
99
90
  qpu_id=settings.qpu_id,
@@ -0,0 +1,20 @@
1
+ """Extract qubit connectivity from an executor device spec for cloud reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ["resolve_connectivity_from_device_spec"]
6
+
7
+
8
+ def resolve_connectivity_from_device_spec(
9
+ device_spec: object | None,
10
+ ) -> list[list[int]] | None:
11
+ """Extract qubit connectivity from a device spec, preferring directed edges.
12
+
13
+ Matches the shape sent in node heartbeats and connect payloads.
14
+ """
15
+ if device_spec is None:
16
+ return None
17
+ directed = getattr(device_spec, "directed_edges", None)
18
+ if directed:
19
+ return [list(e) for e in directed]
20
+ return [list(e) for e in device_spec.logical_edges] # type: ignore[attr-defined]
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING
18
18
 
19
19
  import httpx
20
20
 
21
+ from coda_node.errors import HeartbeatRejectedError
21
22
  from coda_node.server.auth import sign_token
22
23
 
23
24
  if TYPE_CHECKING:
@@ -31,6 +32,20 @@ _DEFAULT_INTERVAL = 30
31
32
  _HTTP_TIMEOUT = 10.0
32
33
 
33
34
 
35
+ def _format_heartbeat_error_response(response: httpx.Response) -> str:
36
+ """Best-effort message from a failed heartbeat response body."""
37
+ try:
38
+ data = response.json()
39
+ if isinstance(data, dict) and "error" in data:
40
+ return str(data["error"])
41
+ except Exception:
42
+ pass
43
+ text = (response.text or "").strip()
44
+ if text:
45
+ return text[:800]
46
+ return response.reason_phrase or "unknown error"
47
+
48
+
34
49
  class HeartbeatClient:
35
50
  """Send periodic authenticated heartbeats to the Coda cloud.
36
51
 
@@ -87,7 +102,12 @@ class HeartbeatClient:
87
102
  json=body,
88
103
  headers={"Authorization": f"Bearer {token}", **self._extra_headers},
89
104
  )
90
- response.raise_for_status()
105
+ if response.is_success:
106
+ return
107
+ detail = _format_heartbeat_error_response(response)
108
+ raise HeartbeatRejectedError(
109
+ f"HTTP {response.status_code} from heartbeat endpoint: {detail}"
110
+ )
91
111
 
92
112
  async def run(self) -> None:
93
113
  """Run the heartbeat loop until :meth:`stop` is called."""
@@ -99,6 +119,13 @@ class HeartbeatClient:
99
119
  try:
100
120
  await self._send()
101
121
  logger.debug("Heartbeat sent to %s", self._url)
122
+ except HeartbeatRejectedError as exc:
123
+ logger.warning(
124
+ "Heartbeat rejected by cloud (%s). Fix configuration or align "
125
+ "qpu_devices.qubits with the node's reported connectivity. %s",
126
+ self._url,
127
+ exc,
128
+ )
102
129
  except Exception:
103
130
  logger.warning("Heartbeat to %s failed", self._url, exc_info=True)
104
131
  await asyncio.sleep(self._interval)
@@ -363,7 +363,34 @@ async def _post_connect(
363
363
  ) from last_exc
364
364
 
365
365
 
366
- async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
366
+ def _connectivity_payload_for_connect(settings: Settings) -> list[list[int]] | None:
367
+ """Load the executor and derive live topology for the connect handshake.
368
+
369
+ If the executor cannot be loaded (missing config, import errors), returns
370
+ ``None`` so connect proceeds without ``connectivity`` (cloud skips validation).
371
+ """
372
+ try:
373
+ from coda_node.server.device_topology import (
374
+ resolve_connectivity_from_device_spec,
375
+ )
376
+ from coda_node.server.executor import load_executor
377
+
378
+ runner = load_executor(settings)
379
+ device_spec = getattr(runner, "device", None)
380
+ return resolve_connectivity_from_device_spec(device_spec)
381
+ except Exception as exc:
382
+ logger.warning(
383
+ "Skipping live connectivity on connect handshake (executor unavailable): %s",
384
+ exc,
385
+ )
386
+ return None
387
+
388
+
389
+ async def fetch_node_bundle(
390
+ settings: Settings,
391
+ *,
392
+ connectivity: list[list[int]] | None = None,
393
+ ) -> dict[str, Any]:
367
394
  """Fetch the provisioning bundle using a one-time node token.
368
395
 
369
396
  POSTs the machine fingerprint to the Coda connect endpoint. The
@@ -383,9 +410,11 @@ async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
383
410
  if not settings.node_token:
384
411
  raise NodeError("node token is empty")
385
412
 
386
- payload = {
413
+ payload: dict[str, Any] = {
387
414
  "machine_fingerprint": _resolve_machine_fingerprint(settings),
388
415
  }
416
+ if connectivity is not None:
417
+ payload["connectivity"] = connectivity
389
418
  return await _post_connect(
390
419
  settings,
391
420
  auth_header=f"Bearer {settings.node_token}",
@@ -394,7 +423,11 @@ async def fetch_node_bundle(settings: Settings) -> dict[str, Any]:
394
423
  )
395
424
 
396
425
 
397
- async def fetch_reconnect_bundle(settings: Settings) -> dict[str, Any]:
426
+ async def fetch_reconnect_bundle(
427
+ settings: Settings,
428
+ *,
429
+ connectivity: list[list[int]] | None = None,
430
+ ) -> dict[str, Any]:
398
431
  """Fetch a reconnect bundle using stored JWT credentials.
399
432
 
400
433
  Authenticates with a freshly signed JWT (instead of a node
@@ -411,9 +444,11 @@ async def fetch_reconnect_bundle(settings: Settings) -> dict[str, Any]:
411
444
  Raises:
412
445
  NodeError: If the HTTP request fails.
413
446
  """
414
- payload = {
447
+ payload: dict[str, Any] = {
415
448
  "machine_fingerprint": _resolve_machine_fingerprint(settings),
416
449
  }
450
+ if connectivity is not None:
451
+ payload["connectivity"] = connectivity
417
452
  token = sign_token(
418
453
  settings.qpu_id,
419
454
  settings.jwt_private_key,
@@ -539,11 +574,12 @@ async def connect_settings(settings: Settings) -> None:
539
574
  NodeError: On provisioning or reconnect failure.
540
575
  """
541
576
  _resolve_machine_fingerprint(settings)
577
+ connectivity = _connectivity_payload_for_connect(settings)
542
578
  if settings.node_token:
543
- bundle = await fetch_node_bundle(settings)
579
+ bundle = await fetch_node_bundle(settings, connectivity=connectivity)
544
580
  else:
545
581
  await ensure_persisted_vpn(settings)
546
- bundle = await fetch_reconnect_bundle(settings)
582
+ bundle = await fetch_reconnect_bundle(settings, connectivity=connectivity)
547
583
  try:
548
584
  await apply_node_bundle(settings, bundle)
549
585
  await asyncio.to_thread(_persist_runtime_config, settings)
@@ -0,0 +1,25 @@
1
+ """Tests for device topology helpers."""
2
+
3
+ from coda_node.server.device_topology import resolve_connectivity_from_device_spec
4
+
5
+
6
+ class _FakeDevice:
7
+ def __init__(
8
+ self, directed: list[tuple[int, int]] | None, logical: list[tuple[int, int]]
9
+ ) -> None:
10
+ self.directed_edges = directed
11
+ self.logical_edges = logical
12
+
13
+
14
+ def test_prefers_directed_edges() -> None:
15
+ spec = _FakeDevice([(1, 0), (2, 1)], [(0, 1)])
16
+ assert resolve_connectivity_from_device_spec(spec) == [[1, 0], [2, 1]]
17
+
18
+
19
+ def test_falls_back_to_logical_edges() -> None:
20
+ spec = _FakeDevice(None, [(0, 1), (1, 2)])
21
+ assert resolve_connectivity_from_device_spec(spec) == [[0, 1], [1, 2]]
22
+
23
+
24
+ def test_none_spec() -> None:
25
+ assert resolve_connectivity_from_device_spec(None) is None
@@ -6,7 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
6
6
 
7
7
  import pytest
8
8
 
9
- from coda_node.server.heartbeat import HeartbeatClient
9
+ from coda_node.errors import HeartbeatRejectedError
10
+ from coda_node.server.heartbeat import HeartbeatClient, _format_heartbeat_error_response
10
11
 
11
12
 
12
13
  def _make_consumer(**overrides: object) -> MagicMock:
@@ -18,6 +19,42 @@ def _make_consumer(**overrides: object) -> MagicMock:
18
19
  return consumer
19
20
 
20
21
 
22
+ def test_format_heartbeat_error_response_prefers_json_error_field() -> None:
23
+ response = MagicMock()
24
+ response.json.return_value = {"error": "Connectivity edge (19, 18) outside [0, 19)"}
25
+ assert (
26
+ _format_heartbeat_error_response(response)
27
+ == "Connectivity edge (19, 18) outside [0, 19)"
28
+ )
29
+
30
+
31
+ @patch("coda_node.server.heartbeat.sign_token", return_value="mock-jwt")
32
+ async def test_send_raises_heartbeat_rejected_on_http_error(
33
+ _mock_sign: MagicMock,
34
+ ) -> None:
35
+ mock_response = MagicMock()
36
+ mock_response.is_success = False
37
+ mock_response.status_code = 400
38
+ mock_response.json.return_value = {"error": "topology mismatch"}
39
+ mock_response.reason_phrase = "Bad Request"
40
+ mock_response.text = '{"error": "topology mismatch"}'
41
+ mock_http = AsyncMock()
42
+ mock_http.post = AsyncMock(return_value=mock_response)
43
+
44
+ consumer = _make_consumer()
45
+ client = HeartbeatClient(
46
+ heartbeat_url="https://example.com/heartbeat",
47
+ qpu_id="qpu-1",
48
+ jwt_private_key="fake-key",
49
+ jwt_key_id="kid-1",
50
+ consumer=consumer,
51
+ )
52
+ client._client = mock_http
53
+
54
+ with pytest.raises(HeartbeatRejectedError, match=r"400.*topology mismatch"):
55
+ await client._send()
56
+
57
+
21
58
  @patch("coda_node.server.heartbeat.sign_token", return_value="mock-jwt")
22
59
  async def test_send_posts_authenticated_payload(_mock_sign: MagicMock) -> None:
23
60
  mock_response = AsyncMock()
@@ -9,7 +9,6 @@ from fastapi import FastAPI, Request
9
9
  from fastapi.responses import JSONResponse
10
10
  from fastapi.testclient import TestClient
11
11
 
12
- from coda_node.server.app import _resolve_connectivity
13
12
  from coda_node.vpn import ServiceState
14
13
 
15
14
 
@@ -104,30 +103,3 @@ def test_ready_endpoint_redis_unhealthy() -> None:
104
103
  body = response.json()
105
104
  assert body["ready"] is False
106
105
  assert body["redis_healthy"] is False
107
-
108
-
109
- def test_resolve_connectivity_prefers_directed_edges() -> None:
110
- device = MagicMock()
111
- device.directed_edges = [(1, 0), (2, 1)]
112
- device.logical_edges = [(0, 1), (1, 2)]
113
-
114
- assert _resolve_connectivity(device) == [[1, 0], [2, 1]]
115
-
116
-
117
- def test_resolve_connectivity_falls_back_to_logical_edges() -> None:
118
- device = MagicMock(spec=["logical_edges"])
119
- device.logical_edges = [(0, 1), (1, 2)]
120
-
121
- assert _resolve_connectivity(device) == [[0, 1], [1, 2]]
122
-
123
-
124
- def test_resolve_connectivity_returns_none_without_device() -> None:
125
- assert _resolve_connectivity(None) is None
126
-
127
-
128
- def test_resolve_connectivity_falls_back_when_directed_edges_empty() -> None:
129
- device = MagicMock()
130
- device.directed_edges = []
131
- device.logical_edges = [(0, 1), (1, 2)]
132
-
133
- assert _resolve_connectivity(device) == [[0, 1], [1, 2]]