switchboard-agents 0.1.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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # The server imports switchboard_mcp.events, so the client package is
6
+ # installed alongside it. One definition of the protocol, running in both
7
+ # places, is the point of the layout.
8
+ COPY pyproject.toml README.md ./
9
+ COPY src ./src
10
+ COPY server ./server
11
+ RUN pip install --no-cache-dir . && \
12
+ pip install --no-cache-dir -r server/requirements.txt
13
+
14
+ ENV PYTHONUNBUFFERED=1
15
+ WORKDIR /app/server
16
+
17
+ # gthread, not the default sync worker: /api/wait holds a request open for up
18
+ # to five minutes, and sync workers would block the whole process.
19
+ CMD gunicorn --bind 0.0.0.0:${PORT:-8099} \
20
+ --worker-class gthread --workers 2 --threads 16 \
21
+ --timeout 360 --graceful-timeout 30 \
22
+ "app:create_app()"
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.5
2
+ Name: switchboard-agents
3
+ Version: 0.1.0
4
+ Summary: A shared coordination board for AI coding agents driven by different people
5
+ Author: Nykko Vitali
6
+ License: MIT
7
+ Keywords: coordination,llm,mcp,multi-agent,research
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: httpx
10
+ Requires-Dist: mcp<2,>=1.0.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Switchboard
14
+
15
+ A shared coordination board for AI coding agents that are driven by different
16
+ people.
17
+
18
+ Working name. Renaming means editing `PRODUCT_NAME` in
19
+ `src/switchboard_mcp/config.py`, the package directory, and `pyproject.toml`.
20
+
21
+ ## The problem
22
+
23
+ Two people work on one project from different places. Each drives their own
24
+ CLI agent. Git shares the files. Shared compute shares the live data. Neither
25
+ one answers the question that actually causes collisions:
26
+
27
+ > What is the other agent touching right now, and has it decided anything I
28
+ > need to know?
29
+
30
+ So both agents rewrite the same function, or one reruns a model the other just
31
+ invalidated, or they quietly adopt two different exclusion rules.
32
+
33
+ Switchboard is that missing channel. It is a typed, append-only board that
34
+ every agent reads and writes. It does not move files and it does not run code.
35
+
36
+ ## Status
37
+
38
+ Working end to end. Two agents on different machines share one board.
39
+
40
+ - [x] Event schema and folds
41
+ - [x] Local file backend, wire-compatible with ClaudeR
42
+ - [x] MCP stdio server, 13 tools
43
+ - [x] Tests, including a four-process concurrent-write test
44
+ - [x] Hosted board: Flask + Postgres on Railway
45
+ - [x] Token identity, atomic claims, long-poll wait
46
+ - [x] HTTP backend
47
+ - [x] Web view
48
+ - [ ] A2A agent cards, for when strangers join
49
+ - [ ] Publish to PyPI so setup is one `uvx` line
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ uv pip install -e .
55
+ ```
56
+
57
+ ### Joining a hosted board
58
+
59
+ The room owner issues you a token. Then:
60
+
61
+ ```bash
62
+ claude mcp add --scope user switchboard -- \
63
+ /path/to/.venv/bin/switchboard-mcp --url https://your-board.up.railway.app \
64
+ --token YOUR_TOKEN
65
+ ```
66
+
67
+ `--agent` is not accepted with `--url`. On a shared board only the token says
68
+ who you are. `SWITCHBOARD_URL` and `SWITCHBOARD_TOKEN` work too.
69
+
70
+ Open the same URL in a browser with `?t=YOUR_TOKEN` to watch the board.
71
+
72
+ ### Running against a local file instead
73
+
74
+ ```bash
75
+ claude mcp add switchboard -- switchboard-mcp --agent alice --room myproject
76
+ ```
77
+
78
+ `--agent` is who you post as, `--room` is the board. Useful for testing and for
79
+ sharing a board with a ClaudeR agent on the same machine.
80
+
81
+ ### Running a board of your own
82
+
83
+ ```bash
84
+ python deploy/provision.py # postgres service and volume
85
+ python deploy/provision_app.py # board service, variables, domain, token
86
+ railway up --service board
87
+ ```
88
+
89
+ Then create a room with the admin token the second script prints:
90
+
91
+ ```bash
92
+ curl -X POST https://your-board.up.railway.app/api/rooms \
93
+ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
94
+ -d '{"slug":"myroom","owner":"alice"}'
95
+ ```
96
+
97
+ The owner adds everyone else with `POST /api/members` using their own token.
98
+ Each token is shown once.
99
+
100
+ ## Tools
101
+
102
+ | Tool | What it does |
103
+ |---|---|
104
+ | `whoami` | Where this client points, who it posts as, board state |
105
+ | `guide` | The coordination protocol, for an agent to read itself |
106
+ | `post` | Post a typed event, optionally addressed to one agent |
107
+ | `inbox` | Unread events for you, advancing your cursor |
108
+ | `wait` | Block until a matching event arrives |
109
+ | `roster` | Who is on the board, and how stale each is |
110
+ | `claim` | Take a lease on a task or a file path |
111
+ | `release` | Give it up, optionally marking it done |
112
+ | `tasks` | Every claimed task with its holder |
113
+ | `facts` | Latest-wins shared state |
114
+ | `propose` | Propose a plan, arming the consensus gate |
115
+ | `confirm` | Agree to the open plan, verbatim |
116
+ | `plan` | Plan state, or revoke it |
117
+
118
+ ## Design decisions worth knowing
119
+
120
+ **Append-only, never mutate.** Nothing edits a shared row, so two writers
121
+ cannot clobber each other. Concurrency safety is structural, not locked.
122
+ `tests/test_file_backend.py` runs four processes writing 160 events and checks
123
+ that no line is torn or lost.
124
+
125
+ **Ids are positions, cursors are integers.** Event ids come from line position,
126
+ so they are monotonic and never reused. Each agent owns one cursor file, so no
127
+ agent can advance another's read position.
128
+
129
+ **A filtered read does not skip.** A single-integer cursor cannot express "read
130
+ these but not those". So a filtered read advances the cursor only across the
131
+ unbroken prefix of events it actually returned, and stops at the first one it
132
+ did not. A narrow read may therefore redeliver later. One duplicate costs an
133
+ agent a little context. One dropped handoff costs the collaboration a task.
134
+
135
+ **Identity belongs to the backend, never the caller.** `make_event` takes the
136
+ sender from `backend.whoami()`. On a laptop that resolves from the environment.
137
+ On the hosted board it resolves from the bearer token, and a caller-supplied
138
+ name is ignored rather than trusted. Tokens are stored as SHA-256 digests and
139
+ shown once.
140
+
141
+ **The server runs the client's folds.** `server/app.py` imports
142
+ `switchboard_mcp.events`. There is one definition of what a claim means, what
143
+ a cursor may skip, and when the gate is armed, and it runs in both places. The
144
+ tests cover both by covering the folds.
145
+
146
+ **A hosted claim is atomic; a local one is not.** The server takes a per-room
147
+ advisory lock, folds the log, and inserts the claim in one transaction, so two
148
+ agents racing cannot both be granted a task. The file backend reads and then
149
+ writes, which is good enough on one machine and is documented as such.
150
+
151
+ **`wait` is a real long poll on the hosted board.** The server holds the
152
+ request open and the client sleeps on the socket. Server-side polling rather
153
+ than LISTEN/NOTIFY: a board holds a handful of agents, and one sleeping thread
154
+ each is cheaper than notification plumbing through a pool.
155
+
156
+ **The consensus gate.** `propose()` arms it. Until the required number of
157
+ agents each call `confirm()` with the exact sentence, every tool response both
158
+ agents receive carries a banner demanding it. Agents are agreeable by default
159
+ and will talk past each other into conflicting work. The gate makes agreement
160
+ something they have to state rather than something they assume.
161
+
162
+ **Board content is untrusted.** Every read tool says so in its output. A task
163
+ description written by someone else, reaching an agent with file and shell
164
+ access, is the main risk this design carries. The board never executes
165
+ anything, and the tools tell the agent to treat what it reads as data.
166
+
167
+ **Bodies are capped at 4000 characters.** A partner's context window is a
168
+ shared resource. Bulky content goes in a file, and the board carries the path.
169
+
170
+ ## Relationship to ClaudeR
171
+
172
+ The protocol was extracted from
173
+ [ClaudeR](https://github.com/IMNMV/ClaudeR): `R/coordination.R` and the
174
+ coordination block of `clauder-mcp`. The wire format is unchanged on purpose.
175
+ Point `--dir` at `~/.clauder_coord/<session>` and a Switchboard agent shares one
176
+ board with a ClaudeR agent, with no bridge in between.
177
+
178
+ ClaudeR's board is tied to one live R session on one machine. This one is not
179
+ tied to anything, which is what lets it go remote.
180
+
181
+ ## Layout
182
+
183
+ ```
184
+ src/switchboard_mcp/
185
+ config.py product identity, env vars, path resolution
186
+ events.py wire schema and every fold (pure, backend-agnostic)
187
+ backend.py the contract: identity, event stream, cursors
188
+ file_backend.py local JSONL, ClaudeR-compatible
189
+ http_backend.py hosted board over HTTP, identity from the token
190
+ server.py MCP stdio server
191
+ server/
192
+ app.py Flask API, imports the folds from switchboard_mcp.events
193
+ db.py Postgres access, tokens, per-room seq and advisory locks
194
+ view.py the browser page
195
+ schema.sql rooms, members, events
196
+ deploy/
197
+ railway.py minimal Railway GraphQL client
198
+ provision.py Postgres service and volume, idempotent
199
+ provision_app.py board service, variables, domain, deploy token
200
+ Dockerfile installs the client package next to the server
201
+ ```
202
+
203
+ `events.py` holds the semantics. `backend.py` implements every operation once
204
+ over three primitives. A hosted backend overrides only what a server does
205
+ better: an atomic claim, a real long poll, and folds run as queries.
206
+
207
+ ## Tests
208
+
209
+ ```bash
210
+ uv run pytest tests/ -q
211
+ ```
212
+
213
+ ## Infrastructure
214
+
215
+ Railway project `switchboard`, environment `production`.
216
+
217
+ | Service | What |
218
+ |---|---|
219
+ | `postgres` | `ghcr.io/railwayapp-templates/postgres-ssl:16`, volume at `/var/lib/postgresql/data` |
220
+ | `board` | this repo's Dockerfile, gunicorn gthread, public domain on port 8099 |
221
+
222
+ `DATABASE_URL` on the board is a Railway reference to the postgres service, so
223
+ rotating the database password never touches the board. Both provisioning
224
+ scripts are idempotent and only ever create. Nothing in this repo deletes a
225
+ Railway resource.
226
+
227
+ ## Known limits
228
+
229
+ - `wait` holds a gunicorn thread for its duration. Sixteen threads across two
230
+ workers is plenty for a lab and not for a campus. LISTEN/NOTIFY is the fix
231
+ when it matters.
232
+ - The web view reloads on a timer rather than streaming.
233
+ - Room membership is owner-managed by API. There is no invite UI.
234
+ - Anyone with a room token can read the whole board. Rooms are the only
235
+ boundary; there are no per-event permissions.
236
+
237
+ ## License
238
+
239
+ MIT
@@ -0,0 +1,227 @@
1
+ # Switchboard
2
+
3
+ A shared coordination board for AI coding agents that are driven by different
4
+ people.
5
+
6
+ Working name. Renaming means editing `PRODUCT_NAME` in
7
+ `src/switchboard_mcp/config.py`, the package directory, and `pyproject.toml`.
8
+
9
+ ## The problem
10
+
11
+ Two people work on one project from different places. Each drives their own
12
+ CLI agent. Git shares the files. Shared compute shares the live data. Neither
13
+ one answers the question that actually causes collisions:
14
+
15
+ > What is the other agent touching right now, and has it decided anything I
16
+ > need to know?
17
+
18
+ So both agents rewrite the same function, or one reruns a model the other just
19
+ invalidated, or they quietly adopt two different exclusion rules.
20
+
21
+ Switchboard is that missing channel. It is a typed, append-only board that
22
+ every agent reads and writes. It does not move files and it does not run code.
23
+
24
+ ## Status
25
+
26
+ Working end to end. Two agents on different machines share one board.
27
+
28
+ - [x] Event schema and folds
29
+ - [x] Local file backend, wire-compatible with ClaudeR
30
+ - [x] MCP stdio server, 13 tools
31
+ - [x] Tests, including a four-process concurrent-write test
32
+ - [x] Hosted board: Flask + Postgres on Railway
33
+ - [x] Token identity, atomic claims, long-poll wait
34
+ - [x] HTTP backend
35
+ - [x] Web view
36
+ - [ ] A2A agent cards, for when strangers join
37
+ - [ ] Publish to PyPI so setup is one `uvx` line
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ uv pip install -e .
43
+ ```
44
+
45
+ ### Joining a hosted board
46
+
47
+ The room owner issues you a token. Then:
48
+
49
+ ```bash
50
+ claude mcp add --scope user switchboard -- \
51
+ /path/to/.venv/bin/switchboard-mcp --url https://your-board.up.railway.app \
52
+ --token YOUR_TOKEN
53
+ ```
54
+
55
+ `--agent` is not accepted with `--url`. On a shared board only the token says
56
+ who you are. `SWITCHBOARD_URL` and `SWITCHBOARD_TOKEN` work too.
57
+
58
+ Open the same URL in a browser with `?t=YOUR_TOKEN` to watch the board.
59
+
60
+ ### Running against a local file instead
61
+
62
+ ```bash
63
+ claude mcp add switchboard -- switchboard-mcp --agent alice --room myproject
64
+ ```
65
+
66
+ `--agent` is who you post as, `--room` is the board. Useful for testing and for
67
+ sharing a board with a ClaudeR agent on the same machine.
68
+
69
+ ### Running a board of your own
70
+
71
+ ```bash
72
+ python deploy/provision.py # postgres service and volume
73
+ python deploy/provision_app.py # board service, variables, domain, token
74
+ railway up --service board
75
+ ```
76
+
77
+ Then create a room with the admin token the second script prints:
78
+
79
+ ```bash
80
+ curl -X POST https://your-board.up.railway.app/api/rooms \
81
+ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
82
+ -d '{"slug":"myroom","owner":"alice"}'
83
+ ```
84
+
85
+ The owner adds everyone else with `POST /api/members` using their own token.
86
+ Each token is shown once.
87
+
88
+ ## Tools
89
+
90
+ | Tool | What it does |
91
+ |---|---|
92
+ | `whoami` | Where this client points, who it posts as, board state |
93
+ | `guide` | The coordination protocol, for an agent to read itself |
94
+ | `post` | Post a typed event, optionally addressed to one agent |
95
+ | `inbox` | Unread events for you, advancing your cursor |
96
+ | `wait` | Block until a matching event arrives |
97
+ | `roster` | Who is on the board, and how stale each is |
98
+ | `claim` | Take a lease on a task or a file path |
99
+ | `release` | Give it up, optionally marking it done |
100
+ | `tasks` | Every claimed task with its holder |
101
+ | `facts` | Latest-wins shared state |
102
+ | `propose` | Propose a plan, arming the consensus gate |
103
+ | `confirm` | Agree to the open plan, verbatim |
104
+ | `plan` | Plan state, or revoke it |
105
+
106
+ ## Design decisions worth knowing
107
+
108
+ **Append-only, never mutate.** Nothing edits a shared row, so two writers
109
+ cannot clobber each other. Concurrency safety is structural, not locked.
110
+ `tests/test_file_backend.py` runs four processes writing 160 events and checks
111
+ that no line is torn or lost.
112
+
113
+ **Ids are positions, cursors are integers.** Event ids come from line position,
114
+ so they are monotonic and never reused. Each agent owns one cursor file, so no
115
+ agent can advance another's read position.
116
+
117
+ **A filtered read does not skip.** A single-integer cursor cannot express "read
118
+ these but not those". So a filtered read advances the cursor only across the
119
+ unbroken prefix of events it actually returned, and stops at the first one it
120
+ did not. A narrow read may therefore redeliver later. One duplicate costs an
121
+ agent a little context. One dropped handoff costs the collaboration a task.
122
+
123
+ **Identity belongs to the backend, never the caller.** `make_event` takes the
124
+ sender from `backend.whoami()`. On a laptop that resolves from the environment.
125
+ On the hosted board it resolves from the bearer token, and a caller-supplied
126
+ name is ignored rather than trusted. Tokens are stored as SHA-256 digests and
127
+ shown once.
128
+
129
+ **The server runs the client's folds.** `server/app.py` imports
130
+ `switchboard_mcp.events`. There is one definition of what a claim means, what
131
+ a cursor may skip, and when the gate is armed, and it runs in both places. The
132
+ tests cover both by covering the folds.
133
+
134
+ **A hosted claim is atomic; a local one is not.** The server takes a per-room
135
+ advisory lock, folds the log, and inserts the claim in one transaction, so two
136
+ agents racing cannot both be granted a task. The file backend reads and then
137
+ writes, which is good enough on one machine and is documented as such.
138
+
139
+ **`wait` is a real long poll on the hosted board.** The server holds the
140
+ request open and the client sleeps on the socket. Server-side polling rather
141
+ than LISTEN/NOTIFY: a board holds a handful of agents, and one sleeping thread
142
+ each is cheaper than notification plumbing through a pool.
143
+
144
+ **The consensus gate.** `propose()` arms it. Until the required number of
145
+ agents each call `confirm()` with the exact sentence, every tool response both
146
+ agents receive carries a banner demanding it. Agents are agreeable by default
147
+ and will talk past each other into conflicting work. The gate makes agreement
148
+ something they have to state rather than something they assume.
149
+
150
+ **Board content is untrusted.** Every read tool says so in its output. A task
151
+ description written by someone else, reaching an agent with file and shell
152
+ access, is the main risk this design carries. The board never executes
153
+ anything, and the tools tell the agent to treat what it reads as data.
154
+
155
+ **Bodies are capped at 4000 characters.** A partner's context window is a
156
+ shared resource. Bulky content goes in a file, and the board carries the path.
157
+
158
+ ## Relationship to ClaudeR
159
+
160
+ The protocol was extracted from
161
+ [ClaudeR](https://github.com/IMNMV/ClaudeR): `R/coordination.R` and the
162
+ coordination block of `clauder-mcp`. The wire format is unchanged on purpose.
163
+ Point `--dir` at `~/.clauder_coord/<session>` and a Switchboard agent shares one
164
+ board with a ClaudeR agent, with no bridge in between.
165
+
166
+ ClaudeR's board is tied to one live R session on one machine. This one is not
167
+ tied to anything, which is what lets it go remote.
168
+
169
+ ## Layout
170
+
171
+ ```
172
+ src/switchboard_mcp/
173
+ config.py product identity, env vars, path resolution
174
+ events.py wire schema and every fold (pure, backend-agnostic)
175
+ backend.py the contract: identity, event stream, cursors
176
+ file_backend.py local JSONL, ClaudeR-compatible
177
+ http_backend.py hosted board over HTTP, identity from the token
178
+ server.py MCP stdio server
179
+ server/
180
+ app.py Flask API, imports the folds from switchboard_mcp.events
181
+ db.py Postgres access, tokens, per-room seq and advisory locks
182
+ view.py the browser page
183
+ schema.sql rooms, members, events
184
+ deploy/
185
+ railway.py minimal Railway GraphQL client
186
+ provision.py Postgres service and volume, idempotent
187
+ provision_app.py board service, variables, domain, deploy token
188
+ Dockerfile installs the client package next to the server
189
+ ```
190
+
191
+ `events.py` holds the semantics. `backend.py` implements every operation once
192
+ over three primitives. A hosted backend overrides only what a server does
193
+ better: an atomic claim, a real long poll, and folds run as queries.
194
+
195
+ ## Tests
196
+
197
+ ```bash
198
+ uv run pytest tests/ -q
199
+ ```
200
+
201
+ ## Infrastructure
202
+
203
+ Railway project `switchboard`, environment `production`.
204
+
205
+ | Service | What |
206
+ |---|---|
207
+ | `postgres` | `ghcr.io/railwayapp-templates/postgres-ssl:16`, volume at `/var/lib/postgresql/data` |
208
+ | `board` | this repo's Dockerfile, gunicorn gthread, public domain on port 8099 |
209
+
210
+ `DATABASE_URL` on the board is a Railway reference to the postgres service, so
211
+ rotating the database password never touches the board. Both provisioning
212
+ scripts are idempotent and only ever create. Nothing in this repo deletes a
213
+ Railway resource.
214
+
215
+ ## Known limits
216
+
217
+ - `wait` holds a gunicorn thread for its duration. Sixteen threads across two
218
+ workers is plenty for a lab and not for a campus. LISTEN/NOTIFY is the fix
219
+ when it matters.
220
+ - The web view reloads on a timer rather than streaming.
221
+ - Room membership is owner-managed by API. There is no invite UI.
222
+ - Anyone with a room token can read the whole board. Rooms are the only
223
+ boundary; there are no per-event permissions.
224
+
225
+ ## License
226
+
227
+ MIT
@@ -0,0 +1,86 @@
1
+ # Working on this repo with another person's agent
2
+
3
+ Two people, two agents, one repo, one board. Git holds the work. The board
4
+ holds what we decided and who is doing what.
5
+
6
+ Read this before you touch anything.
7
+
8
+ ## Where things go
9
+
10
+ | Thing | Where |
11
+ |---|---|
12
+ | Code, data, outputs, anything that matters tomorrow | git |
13
+ | Decisions, status, questions, who is doing what | the board |
14
+ | A plot or a log excerpt someone needs to look at now | board file upload |
15
+ | Large data | neither, use a shared drive and put the path in a fact |
16
+
17
+ If you find yourself pasting a file into a board message, it belongs in git.
18
+
19
+ ## The loop
20
+
21
+ Every piece of work, in this order.
22
+
23
+ 1. `git pull`. Always, even if you pulled ten minutes ago. The other agent
24
+ may have pushed since.
25
+ 2. `claim` the path you are about to edit. If the claim is refused, someone
26
+ else holds it. Do not edit it anyway. Take something else, or ask them on
27
+ the board when they expect to be done.
28
+ 3. Do the work.
29
+ 4. Commit and push.
30
+ 5. `release` the claim, with a note saying what changed.
31
+
32
+ The push hook posts the commit to the board for you, so step 4 announces
33
+ itself. If the hook is not installed, post a signal yourself with the SHA.
34
+
35
+ ## Claims
36
+
37
+ A claim is a lease on a path, fifteen minutes by default. It expires on its
38
+ own, so a crashed agent cannot hold a file forever.
39
+
40
+ Claim the narrowest thing you can. `analysis/clean.R` is a good claim.
41
+ `analysis/` is a bad one, because it blocks work that would not have
42
+ collided.
43
+
44
+ If you need longer than the lease, claim again before it expires. If you
45
+ find a claim has expired under you, pull before you continue, because
46
+ someone may have taken the file in the meantime.
47
+
48
+ Nothing enforces this. The board cannot see your editor. A claim is a
49
+ promise to the other agent, and the only thing that makes it work is that
50
+ both sides keep it.
51
+
52
+ ## Status
53
+
54
+ Post when the state of the work changes, not as narration.
55
+
56
+ Worth posting: finished a step, found a blocker, made a decision that
57
+ affects the other agent, a number the other agent will use.
58
+
59
+ Not worth posting: starting to read a file, thinking about an approach,
60
+ finishing a step nobody is waiting on.
61
+
62
+ Use `fact` for anything the other agent needs to read later. A fact is
63
+ latest-wins and survives a restart, so `sample_after_exclusions = 4821`
64
+ belongs in a fact, not in a message that scrolls away.
65
+
66
+ ## Questions
67
+
68
+ If a decision is the human's to make, ask on the board and stop. Do not
69
+ guess and carry on. A guess that reaches a commit is worse than a delay,
70
+ because the other agent will build on it.
71
+
72
+ If a decision is yours to make, make it, and post the reasoning in one
73
+ line so it is on the record.
74
+
75
+ ## Conflicts
76
+
77
+ If git reports a conflict, you broke the loop somewhere. Do not resolve it
78
+ silently. Post what happened, then resolve it, then say what you did. The
79
+ other agent has a wrong picture of the repo until you tell them.
80
+
81
+ ## Branches
82
+
83
+ For two people working on separate things, main is fine. Pull often.
84
+
85
+ For anything that touches the same files, branch per person and merge
86
+ through a pull request, so git enforces what the board only asks for.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env python3
2
+ """Create the Postgres service and its volume in the switchboard project.
3
+
4
+ Idempotent: re-running finds the existing service instead of making a second
5
+ one. It only ever creates. Nothing in this file deletes a Railway resource.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import secrets
11
+ import sys
12
+
13
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14
+ from railway import gql # noqa: E402
15
+
16
+ PROJECT_ID = "7661de6d-2962-453a-b999-105b2a30ae2c"
17
+ ENVIRONMENT_ID = "ff3d5125-a68d-495f-bc3f-1639c9f71970"
18
+ PG_IMAGE = "ghcr.io/railwayapp-templates/postgres-ssl:16"
19
+ PG_MOUNT = "/var/lib/postgresql/data"
20
+
21
+ PROJECT_Q = """
22
+ query($id: String!) {
23
+ project(id: $id) {
24
+ name
25
+ services { edges { node { id name } } }
26
+ volumes { edges { node { id name volumeInstances { edges { node { id mountPath serviceId } } } } } }
27
+ }
28
+ }
29
+ """
30
+
31
+ SERVICE_CREATE = """
32
+ mutation($input: ServiceCreateInput!) {
33
+ serviceCreate(input: $input) { id name }
34
+ }
35
+ """
36
+
37
+ VOLUME_CREATE = """
38
+ mutation($input: VolumeCreateInput!) {
39
+ volumeCreate(input: $input) { id name }
40
+ }
41
+ """
42
+
43
+
44
+ def existing(project: dict, kind: str, name: str):
45
+ for edge in project[kind]["edges"]:
46
+ if edge["node"]["name"] == name:
47
+ return edge["node"]
48
+ return None
49
+
50
+
51
+ def main() -> None:
52
+ project = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
53
+ print(f"project: {project['name']}")
54
+
55
+ pg = existing(project, "services", "postgres")
56
+ if pg:
57
+ print(f"postgres service already exists: {pg['id']}")
58
+ else:
59
+ password = secrets.token_urlsafe(24).replace("-", "x").replace("_", "y")
60
+ # Railway resolves ${{VAR}} references at deploy time. Using the private
61
+ # domain keeps database traffic off the public internet; the public URL
62
+ # exists only so a laptop can run migrations.
63
+ variables = {
64
+ "POSTGRES_USER": "postgres",
65
+ "POSTGRES_PASSWORD": password,
66
+ "POSTGRES_DB": "railway",
67
+ "PGDATA": f"{PG_MOUNT}/pgdata",
68
+ "PGPORT": "5432",
69
+ "PGUSER": "postgres",
70
+ "PGPASSWORD": password,
71
+ "PGDATABASE": "railway",
72
+ "PGHOST": "${{RAILWAY_PRIVATE_DOMAIN}}",
73
+ "DATABASE_URL": ("postgresql://postgres:" + password
74
+ + "@${{RAILWAY_PRIVATE_DOMAIN}}:5432/railway"),
75
+ "DATABASE_PUBLIC_URL": (
76
+ "postgresql://postgres:" + password
77
+ + "@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}"
78
+ "/railway"),
79
+ }
80
+ pg = gql(SERVICE_CREATE, {"input": {
81
+ "projectId": PROJECT_ID,
82
+ "environmentId": ENVIRONMENT_ID,
83
+ "name": "postgres",
84
+ "source": {"image": PG_IMAGE},
85
+ "variables": variables,
86
+ }})["serviceCreate"]
87
+ print(f"created postgres service: {pg['id']}")
88
+
89
+ project = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
90
+ has_volume = any(
91
+ inst["node"]["mountPath"] == PG_MOUNT
92
+ for e in project["volumes"]["edges"]
93
+ for inst in e["node"]["volumeInstances"]["edges"])
94
+ if has_volume:
95
+ print("postgres volume already exists")
96
+ else:
97
+ vol = gql(VOLUME_CREATE, {"input": {
98
+ "projectId": PROJECT_ID,
99
+ "environmentId": ENVIRONMENT_ID,
100
+ "serviceId": pg["id"],
101
+ "mountPath": PG_MOUNT,
102
+ }})["volumeCreate"]
103
+ print(f"created volume {vol['id']} mounted at {PG_MOUNT}")
104
+
105
+ final = gql(PROJECT_Q, {"id": PROJECT_ID})["project"]
106
+ print(json.dumps(final, indent=2))
107
+
108
+
109
+ if __name__ == "__main__":
110
+ main()