Repository navigation
Expand file tree
/
Copy pathrailway.test.ts
More file actions
301 lines (287 loc) · 11.5 KB
/
Copy pathrailway.test.ts
File metadata and controls
301 lines (287 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
import {
createRailwayContext,
project,
projectDefinitionToGraph,
validateGraph,
type RailwayGraph,
type ServiceNode,
} from "railway/iac";
import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import railwayProgram from "../.railway/railway.js";
/** The scripts `pnpm <name>` can actually resolve at the repository root. */
const packageScripts: Record<string, string> = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
).scripts;
/**
* The compiled deployment graph.
*
* Evaluated in this process rather than by shelling out to `railway`'s bin.
* The subprocess booted a second Node, loaded the whole `railway` bundle again
* and re-compiled the config through `tsx` — between 0.3s and 6.2s depending on
* what else the machine was doing, which straddles vitest's default timeout and
* went red on an unmodified config. Raising the timeout only moves the number
* the flake has to beat; removing the second process removes the variance. This
* is the same sequence the bin runs (`resolveDefinition` then
* `projectDefinitionToGraph`), against the compiler vitest has already warmed.
*/
async function railwayGraph(): Promise<RailwayGraph> {
const graph = projectDefinitionToGraph(
await railwayProgram(createRailwayContext({}), project),
);
// What the bin reports as `ok: false` with diagnostics attached.
expect(validateGraph(graph)).toEqual([]);
return graph;
}
/** The one service called `name`, or a failure that says which one is missing. */
function serviceNamed(graph: RailwayGraph, name: string): ServiceNode {
const service = graph.resources.find(
(candidate): candidate is ServiceNode =>
candidate.type === "service" && candidate.name === name,
);
if (!service) {
throw new Error(
`no service called ${name}; the graph has ${graph.resources
.map((resource) => resource.name)
.join(", ")}`,
);
}
return service;
}
/**
* Every variable name a service carries, sorted.
*
* `toMatchObject` only reads the keys it is handed, so it is blind to a
* variable that should not be there at all — a credential belonging to one
* service quietly added to the other passes it without complaint. The whole
* name list is compared instead.
*/
function variableNames(service: ServiceNode): string[] {
return Object.keys(service.variables ?? {}).sort();
}
/**
* What both services must say about restarts and health checks.
*
* Its own constant because the previous version of this file typed `deploy` as
* `{ startCommand, healthcheckPath }` and asserted nothing else: deleting the
* restart policy from both services, or setting the health-check timeout to a
* second, left the suite green. A service that never restarts after a crash is
* the failure this deployment config exists to prevent.
*/
const RESILIENCE = {
// Five minutes: the agent installs nothing at boot but does import the model
// and MCP clients, and the runtime waits on the agent.
healthcheckTimeout: 300,
// Restart a crashed container, and stop after five so a container that
// cannot start does not restart forever without anyone noticing.
restartPolicyType: "ON_FAILURE",
restartPolicyMaxRetries: 5,
} as const;
describe("Railway deployment graph", () => {
it("ships the Python agent and Chromium-capable runtime services", async () => {
const graph = await railwayGraph();
expect(graph.resources.map(({ name }) => name).sort()).toEqual([
"agent",
"runtime",
]);
const agent = serviceNamed(graph, "agent");
// The whole source object, not a subset: `rootDirectory` is what decides
// which tree Railpack builds, and `toMatchObject` reads only the keys it is
// handed — so it is blind to the one that should not be there. The runtime
// builds the repository root and the agent builds `agent/`; swapping either
// gives a service that builds and then cannot start.
expect(agent.source).toEqual({
type: "github",
repo: "CopilotKit/OpenTag",
branch: "main",
rootDirectory: "agent",
});
expect(agent).toMatchObject({
build: {
builder: "RAILPACK",
},
deploy: {
startCommand: 'uvicorn main:app --host "" --port ${PORT:-8123}',
healthcheckPath: "/health",
...RESILIENCE,
},
});
expect(agent.variables).toMatchObject({
AGENT_DISPLAY_NAME: { type: "preserve" },
OPENAI_API_KEY: { type: "preserve" },
TAVILY_API_KEY: { type: "preserve" },
PARALLEL_API_KEY: { type: "preserve" },
WEB_SEARCH_PROVIDER: { type: "preserve" },
GITHUB_PERSONAL_ACCESS_TOKEN: { type: "preserve" },
GITHUB_CODER_TOKEN: { type: "preserve" },
GITHUB_APP_ID: { type: "preserve" },
GITHUB_APP_INSTALLATION_ID: { type: "preserve" },
GITHUB_APP_PRIVATE_KEY_BASE64: { type: "preserve" },
GITHUB_MCP_URL: { type: "preserve" },
POSTHOG_PERSONAL_API_KEY: { type: "preserve" },
POSTHOG_MCP_URL: { type: "preserve" },
LINEAR_API_KEY: { type: "preserve" },
NOTION_MCP_URL: { type: "preserve" },
NOTION_MCP_AUTH_TOKEN: { type: "preserve" },
COMPOSIO_API_KEY: { type: "preserve" },
COMPOSIO_TOOLKITS: { type: "preserve" },
COMPOSIO_USER_TOOLKITS: { type: "preserve" },
COMPOSIO_APPROVALS: { type: "preserve" },
COMPOSIO_WORKSPACE_USER_ID: { type: "preserve" },
COMPOSIO_AUTH_CONFIGS: { type: "preserve" },
AGENT_AUTH_HEADER: { type: "preserve" },
// The agent derives the default Composio workspace user id from this, so
// it has to reach the agent and not only the runtime.
INTELLIGENCE_CHANNEL_NAME: { type: "literal", value: "open-tag" },
// The port the start command falls back to and the port the runtime is
// told to reach it on.
PORT: { type: "literal", value: "8123" },
});
// The agent holds the Composio and Arcade keys and every source
// credential; the runtime must not. Named exhaustively so a credential added
// to the wrong service is a failure rather than an unread key.
expect(variableNames(agent)).toEqual([
"AGENT_AUTH_HEADER",
"AGENT_DISPLAY_NAME",
"ARCADE_API_KEY",
"ARCADE_APPROVALS",
"ARCADE_IDENTITY_NAMESPACE",
"ARCADE_TOOLKITS",
"ARCADE_USER_TOOLKITS",
"ARCADE_WORKSPACE_USER_ID",
"COMPOSIO_API_KEY",
"COMPOSIO_APPROVALS",
"COMPOSIO_AUTH_CONFIGS",
"COMPOSIO_TOOLKITS",
"COMPOSIO_USER_TOOLKITS",
"COMPOSIO_WORKSPACE_USER_ID",
"DAYTONA_API_KEY",
"DAYTONA_SNAPSHOT",
"DAYTONA_TTL_MINUTES",
"GITHUB_APP_ID",
"GITHUB_APP_INSTALLATION_ID",
"GITHUB_APP_PRIVATE_KEY_BASE64",
"GITHUB_CODER_TOKEN",
"GITHUB_MCP_URL",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"INTELLIGENCE_CHANNEL_NAME",
"LINEAR_API_KEY",
"NOTION_MCP_AUTH_TOKEN",
"NOTION_MCP_URL",
"OPENAI_API_KEY",
"PARALLEL_API_KEY",
"PORT",
"POSTHOG_MCP_URL",
"POSTHOG_PERSONAL_API_KEY",
"TAVILY_API_KEY",
"WEB_SEARCH_PROVIDER",
]);
const runtime = serviceNamed(graph, "runtime");
// No `rootDirectory`, asserted by absence: the runtime is the repository
// root, and a subset match passes just as happily with it pointed at
// `agent/` — where `pnpm runtime` is not a script and the deploy restarts
// until it gives up.
expect(runtime.source).toEqual({
type: "github",
repo: "CopilotKit/OpenTag",
branch: "main",
});
expect(runtime).toMatchObject({
build: {
builder: "RAILPACK",
buildCommand: "pnpm exec playwright install chromium",
watchPatterns: [],
},
deploy: {
startCommand: "pnpm runtime",
healthcheckPath: "/api/copilotkit/info",
...RESILIENCE,
},
variables: {
AGENT_DISPLAY_NAME: { type: "preserve" },
AGENT_URL: {
type: "literal",
value: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/",
},
INTELLIGENCE_API_KEY: { type: "preserve" },
INTELLIGENCE_API_URL: {
type: "literal",
value: "https://api.intelligence.copilotkit.ai",
},
INTELLIGENCE_GATEWAY_WS_URL: {
type: "literal",
value: "wss://realtime.intelligence.copilotkit.ai",
},
INTELLIGENCE_LEARNING_CONTAINER_ID: { type: "preserve" },
INTELLIGENCE_CHANNEL_NAME: {
type: "literal",
value: "open-tag",
},
AGENT_AUTH_HEADER: { type: "preserve" },
PUBLIC_URL: { type: "preserve" },
PLAYWRIGHT_BROWSERS_PATH: {
type: "literal",
value: "0",
},
RAILPACK_DEPLOY_APT_PACKAGES: {
type: "literal",
value: expect.stringContaining("libnss3"),
},
},
});
// The runtime carries the shared secret it presents to the agent, its own
// public address for per-person Arcade, and no platform, Composio or Arcade
// credential: Intelligence owns the Slack and Teams edges, and the toolkits
// live on the agent. Asserted as the complete set so a token added back here
// fails rather than passes unnoticed.
expect(variableNames(runtime)).toEqual([
"AGENT_AUTH_HEADER",
"AGENT_DISPLAY_NAME",
"AGENT_URL",
"INTELLIGENCE_API_KEY",
"INTELLIGENCE_API_URL",
"INTELLIGENCE_CHANNEL_NAME",
"INTELLIGENCE_GATEWAY_WS_URL",
"INTELLIGENCE_LEARNING_CONTAINER_ID",
"PLAYWRIGHT_BROWSERS_PATH",
"PORT",
"PUBLIC_URL",
"RAILPACK_DEPLOY_APT_PACKAGES",
]);
// No public domain is declared. A Composio deployment, or Arcade with
// shared toolkits only, must not grow one just because the config supports
// per-person Arcade — the operator opts in by generating a domain.
expect(runtime.networking?.serviceDomains ?? {}).toEqual({});
expect(runtime.networking?.customDomains ?? {}).toEqual({});
});
it("starts each service with an entry point this repository actually has", async () => {
// Replaces a cross-check that compared the two services'
// `INTELLIGENCE_CHANNEL_NAME` values. Both are pinned to the literal
// `open-tag` by the exhaustive assertions above, so the comparison could
// not fail unless one of those failed first — it read as a drift guard and
// was arithmetic on two constants.
//
// This is the part of the config nothing else can see: the start commands
// are strings here and names elsewhere. Renaming the `runtime` script in
// `package.json`, or moving the agent's ASGI module, leaves every
// assertion above green and every deploy exiting at boot — which Railway
// then restarts five times and stops.
const graph = await railwayGraph();
const runtimeStart = serviceNamed(graph, "runtime").deploy?.startCommand;
const scriptName = /^pnpm (?:run )?([\w:-]+)$/.exec(runtimeStart ?? "")?.[1];
expect({ runtimeStart, scriptName }).toMatchObject({
scriptName: expect.any(String),
});
expect(Object.keys(packageScripts)).toContain(scriptName);
// `uvicorn <module>:<attribute>`, resolved from the agent's own
// `rootDirectory`, so the module is a file in `agent/`.
const agentStart = serviceNamed(graph, "agent").deploy?.startCommand ?? "";
const moduleName = /^uvicorn ([\w.]+):(\w+)/.exec(agentStart)?.[1];
expect({ agentStart, moduleName }).toMatchObject({
moduleName: expect.any(String),
});
expect(
existsSync(new URL(`../agent/${moduleName}.py`, import.meta.url)),
).toBe(true);
});
});