ctrlrelay 0.1.11__tar.gz → 0.2.0__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 (97) hide show
  1. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/CHANGELOG.md +136 -0
  2. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/operations.md +13 -8
  4. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/pyproject.toml +1 -1
  5. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/cli.py +503 -25
  6. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/config.py +17 -0
  7. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/github.py +35 -6
  8. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/pr_verifier.py +67 -14
  9. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/state.py +150 -0
  10. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/worktree.py +176 -12
  11. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/dev.py +457 -51
  12. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/post_merge.py +150 -18
  13. ctrlrelay-0.2.0/tests/test_cli_repos.py +268 -0
  14. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_dev_integration.py +148 -7
  15. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_dev_pipeline.py +574 -11
  16. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_github.py +39 -0
  17. ctrlrelay-0.2.0/tests/test_post_merge.py +1312 -0
  18. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_pr_verifier.py +147 -0
  19. ctrlrelay-0.2.0/tests/test_state.py +594 -0
  20. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_worktree.py +485 -12
  21. ctrlrelay-0.1.11/tests/test_post_merge.py +0 -666
  22. ctrlrelay-0.1.11/tests/test_state.py +0 -271
  23. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  25. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  26. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/dependabot.yml +0 -0
  27. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/workflows/build.yml +0 -0
  28. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/workflows/cla.yml +0 -0
  29. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/workflows/pages.yml +0 -0
  30. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/workflows/publish.yml +0 -0
  31. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.github/workflows/test.yml +0 -0
  32. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/.gitignore +0 -0
  33. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/CODE_OF_CONDUCT.md +0 -0
  34. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/CONTRIBUTING.md +0 -0
  35. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/LICENSE +0 -0
  36. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/README.md +0 -0
  37. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/SECURITY.md +0 -0
  38. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/config/orchestrator.yaml.example +0 -0
  39. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/Gemfile +0 -0
  40. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/_config.yml +0 -0
  41. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/architecture.md +0 -0
  42. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/bridge.md +0 -0
  43. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/cli.md +0 -0
  44. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/configuration.md +0 -0
  45. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/development.md +0 -0
  46. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/feedback-loop.md +0 -0
  47. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/getting-started.md +0 -0
  48. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/docs/index.md +0 -0
  49. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/__init__.py +0 -0
  50. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  51. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  52. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  53. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/bridge/server.py +0 -0
  54. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  55. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/__init__.py +0 -0
  56. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/audit.py +0 -0
  57. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  58. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  59. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/obs.py +0 -0
  60. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/poller.py +0 -0
  61. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  62. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/core/scheduler.py +0 -0
  63. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  64. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/dashboard/client.py +0 -0
  65. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  66. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/base.py +0 -0
  67. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/secops.py +0 -0
  68. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/pipelines/task.py +0 -0
  69. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/transports/__init__.py +0 -0
  70. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/transports/base.py +0 -0
  71. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  72. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/src/ctrlrelay/transports/socket_client.py +0 -0
  73. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/__init__.py +0 -0
  74. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/conftest.py +0 -0
  75. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_audit.py +0 -0
  76. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_bridge_protocol.py +0 -0
  77. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_bridge_server.py +0 -0
  78. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_checkpoint.py +0 -0
  79. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_cli_ci_wait.py +0 -0
  80. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_cli_dev.py +0 -0
  81. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_cli_secops.py +0 -0
  82. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_cli_start.py +0 -0
  83. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_cli_version.py +0 -0
  84. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_config.py +0 -0
  85. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_dashboard_client.py +0 -0
  86. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_dispatcher.py +0 -0
  87. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_docs_site.py +0 -0
  88. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_obs.py +0 -0
  89. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_pipeline_base.py +0 -0
  90. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_poller.py +0 -0
  91. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_pr_watcher.py +0 -0
  92. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_scheduler.py +0 -0
  93. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_secops_integration.py +0 -0
  94. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_secops_pipeline.py +0 -0
  95. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_task_pipeline.py +0 -0
  96. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_telegram_handler.py +0 -0
  97. {ctrlrelay-0.1.11 → ctrlrelay-0.2.0}/tests/test_transport.py +0 -0
@@ -7,6 +7,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-04-27
11
+
12
+ Minor release. Adds bulk repo operations driven by
13
+ `config/orchestrator.yaml`, plus a batch of dev-pipeline correctness
14
+ fixes (worktree ownership, PR-CI lock contention, watcher
15
+ persistence) and a docs cleanup.
16
+
17
+ ### Added
18
+
19
+ - **`ctrlrelay repos clone-all/pull-all/status`** (closes #117).
20
+ Stand up an isolated workspace from the orchestrator manifest in
21
+ one command:
22
+
23
+ ```
24
+ ctrlrelay repos clone-all ~/code/myproject [--filter ORG] [--dry-run]
25
+ ctrlrelay repos pull-all ~/code/myproject [--filter ORG] [--dry-run]
26
+ ctrlrelay repos status ~/code/myproject [--filter ORG]
27
+ ```
28
+
29
+ Each repo lands at `DEST/<org>/<repo>` derived from the `name:`
30
+ field; remote is `git@github.com:{name}.git`. The configured
31
+ `local_path` is ignored when `DEST` is passed, so existing
32
+ `~/Projects/...` checkouts stay untouched. Replaces the legacy
33
+ `bkp/sync` shell scripts that broke when the manifest was
34
+ archived during the rewrite.
35
+ - **`RepoConfig.name` validator.** Repo names are now validated
36
+ against `^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$` at config load.
37
+ Rejects `..`, extra slashes, and shell metacharacters before they
38
+ reach a clone target — defense in depth for the new bulk
39
+ commands.
40
+
41
+ ### Fixed
42
+
43
+ - **Watchers persist across poller restarts** (closes #57). Adds
44
+ a `pr_watches` state-db table so in-flight merge watchers
45
+ survive launchd kickstart, crashes, and reboots. Before this,
46
+ any PR sitting in review across a poller restart silently lost
47
+ its post-merge automation (issue auto-close + Telegram
48
+ notification) for the rest of its 7-day window. The poller now
49
+ rehydrates surviving rows on startup and spawns one watcher
50
+ task per row.
51
+ - **Repo lock released during PR CI verification** (closes #29).
52
+ `run_dev_issue` used to hold the per-repo lock through the
53
+ `PRVerifier.wait_for_checks` polling window — a pure `gh` poll
54
+ that can run for up to 30 minutes — which made every peer
55
+ session targeting the same repo report "Repository locked by
56
+ another session" while no git work was in flight. The lock is
57
+ now released before `verify`, reacquired only if a `request_fix`
58
+ follow-up needs to spawn an agent against the worktree, and
59
+ released again on cleanup. `CancelledError` during the unlocked
60
+ window propagates without leaking a lock row.
61
+ - **Branch ownership signal survives delete+recreate** (closes #51).
62
+ `create_worktree_with_new_branch` now returns
63
+ `(path, created_fresh)` so the caller knows whether THIS session
64
+ created the branch (fresh from default, or via the stale-merged
65
+ delete+recreate path). Before #51, `run_dev_issue` snapshotted
66
+ `branch_preexisted` BEFORE the call; the snapshot went stale the
67
+ moment the helper detected a fully-merged local branch and
68
+ deleted+recreated it. A FAILED cleanup would then skip
69
+ `delete_branch` and leak partial commits into the next retry.
70
+ - **Refuse reuse when branch still backs an open PR** (closes #52).
71
+ `create_worktree_with_new_branch` now probes GitHub (via
72
+ `GitHubCLI.list_prs(head=...)`) before reusing an existing local
73
+ branch. If an open PR still backs it (prior DONE session whose
74
+ PR is unmerged, or any external source), raises `WorktreeError`
75
+ with the PR number and a concrete operator action instead of
76
+ silently hijacking the reviewer's already-reviewed branch or
77
+ tripping "A pull request already exists" at `gh pr create`.
78
+ - **`pull-all` checks subprocess return codes.** `git status`
79
+ failure no longer treats an empty stdout as "clean" and proceeds
80
+ to pull a corrupt repo. `git fetch` failure on a dirty tree is
81
+ now reported as `failed` instead of silently being counted as
82
+ `dirty — fetched only`.
83
+ - **`status` no longer crashes on edge cases.** `git rev-list`
84
+ parsing wrapped in a helper that returns 0 on any non-zero
85
+ return code or non-numeric output, instead of raising on
86
+ `int(ahead)`.
87
+
88
+ ### Changed
89
+
90
+ - **Docs use `com.example.*` placeholder for launchd labels**
91
+ (closes #23). The launchd plist examples and `launchctl`
92
+ commands no longer hard-code `com.ainvirion.ctrlrelay-*` as the
93
+ job label. Anyone copying the docs verbatim picked up that label
94
+ too, which is fine until two forks of the project share a
95
+ machine. Swapped to `com.example.ctrlrelay-*` with a one-line
96
+ note directing readers to use a reverse-DNS prefix they own.
97
+
98
+ ### Operator notes
99
+
100
+ - Upgrade via `uv tool install ctrlrelay@latest --force` and
101
+ restart poller + bridge.
102
+ No schema or config changes — the new `pr_watches` state-db
103
+ table is created idempotently on first start.
104
+ - New workflow: `ctrlrelay repos clone-all ~/code/myproject` to
105
+ stand up a fresh workspace, `repos pull-all` to refresh it.
106
+ Existing `~/Projects/...` checkouts are not touched.
107
+
108
+ ## [0.1.12] - 2026-04-22
109
+
110
+ Patch release. Closes #90 — three related polish items on the
111
+ `ctrlrelay ci wait` helper / `PRVerifier.wait_for_checks` polling
112
+ loop, plus a fail-closed safety fix caught by codex on the PR.
113
+
114
+ ### Fixed
115
+
116
+ - **Short timeouts now honored.** `wait_for_checks` used to block
117
+ the full `poll_interval` before noticing a shorter `--timeout`
118
+ budget was over. Repro: `ctrlrelay ci wait --timeout 1
119
+ --interval 15` returned in ~15s instead of ~1s. Now the per-
120
+ iteration sleep is capped at the remaining wall-clock deadline.
121
+ - **Transient `gh` errors no longer leak as tracebacks.**
122
+ `asyncio.TimeoutError` from a hung `gh` subprocess used to
123
+ surface as an unhandled Python stack trace. The polling loop now
124
+ catches it (and `GitHubError`) inside the loop, logs
125
+ `pr_verifier.transient_gh_error`, and retries the next tick.
126
+ - **Persistent `gh` failures now fail closed** (codex P1). If
127
+ every poll errors up to the deadline and no successful read ever
128
+ happened, `wait_for_checks` raises the last transient error
129
+ instead of returning `[]`. Without this, the empty list was
130
+ misread by callers as "no CI configured" and silently
131
+ greenlighted PRs while GitHub was down.
132
+ - **Wall-clock deadline.** Switched from accumulated-sleep elapsed
133
+ tracking to a monotonic `loop.time()` deadline, which is correct
134
+ even with `poll_interval=0` and unaffected by wall-clock jumps.
135
+
136
+ ### Operator notes
137
+
138
+ - Upgrade via `uv tool install ctrlrelay@latest --force` and
139
+ restart poller + bridge. No schema or config changes.
140
+ - `ctrlrelay ci wait --pr <N> --timeout <s>` invocations with
141
+ short timeouts will now return promptly. Existing dev-pipeline
142
+ PR verification calls behave identically except in the
143
+ GitHub-fully-down scenario, where they will now surface a clean
144
+ failure instead of silently passing.
145
+
10
146
  ## [0.1.11] - 2026-04-22
11
147
 
12
148
  Patch release. Fixes a stale-bare-repo bug that caused worktrees
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrlrelay
3
- Version: 0.1.11
3
+ Version: 0.2.0
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -33,7 +33,12 @@ and restart on failure — the unit examples below already do this.
33
33
  Save plist files under `~/Library/LaunchAgents/`. Use the **absolute** path to
34
34
  `ctrlrelay` (run `which ctrlrelay` to find it) — launchd's PATH is minimal.
35
35
 
36
- `~/Library/LaunchAgents/com.ainvirion.ctrlrelay-bridge.plist`:
36
+ The job labels below use `com.example.ctrlrelay-*` as a placeholder. Pick a
37
+ reverse-DNS prefix you own (e.g. `com.yourname.ctrlrelay-*`) and use it
38
+ consistently across the filename, the `<Label>` value, and the `launchctl`
39
+ commands so `launchctl list` output is unambiguous.
40
+
41
+ `~/Library/LaunchAgents/com.example.ctrlrelay-bridge.plist`:
37
42
 
38
43
  {% raw %}
39
44
  ```xml
@@ -42,7 +47,7 @@ Save plist files under `~/Library/LaunchAgents/`. Use the **absolute** path to
42
47
  <plist version="1.0">
43
48
  <dict>
44
49
  <key>Label</key>
45
- <string>com.ainvirion.ctrlrelay-bridge</string>
50
+ <string>com.example.ctrlrelay-bridge</string>
46
51
  <key>ProgramArguments</key>
47
52
  <array>
48
53
  <string>/opt/homebrew/bin/ctrlrelay</string>
@@ -72,7 +77,7 @@ Save plist files under `~/Library/LaunchAgents/`. Use the **absolute** path to
72
77
  ```
73
78
  {% endraw %}
74
79
 
75
- `~/Library/LaunchAgents/com.ainvirion.ctrlrelay-poller.plist`:
80
+ `~/Library/LaunchAgents/com.example.ctrlrelay-poller.plist`:
76
81
 
77
82
  {% raw %}
78
83
  ```xml
@@ -81,7 +86,7 @@ Save plist files under `~/Library/LaunchAgents/`. Use the **absolute** path to
81
86
  <plist version="1.0">
82
87
  <dict>
83
88
  <key>Label</key>
84
- <string>com.ainvirion.ctrlrelay-poller</string>
89
+ <string>com.example.ctrlrelay-poller</string>
85
90
  <key>ProgramArguments</key>
86
91
  <array>
87
92
  <string>/opt/homebrew/bin/ctrlrelay</string>
@@ -122,16 +127,16 @@ Create the log directory and load the agents:
122
127
 
123
128
  ```bash
124
129
  mkdir -p ~/.ctrlrelay/logs
125
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ainvirion.ctrlrelay-bridge.plist
126
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ainvirion.ctrlrelay-poller.plist
130
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.ctrlrelay-bridge.plist
131
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.ctrlrelay-poller.plist
127
132
  ```
128
133
 
129
134
  Manage them:
130
135
 
131
136
  ```bash
132
137
  launchctl list | grep ctrlrelay # check loaded
133
- launchctl bootout gui/$(id -u)/com.ainvirion.ctrlrelay-poller # stop
134
- launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.ainvirion.ctrlrelay-poller.plist # start
138
+ launchctl bootout gui/$(id -u)/com.example.ctrlrelay-poller # stop
139
+ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.ctrlrelay-poller.plist # start
135
140
  ```
136
141
 
137
142
  ### Linux — systemd
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.1.11"
3
+ version = "0.2.0"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"