@polycore/runner 0.1.0
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.
- package/README.md +261 -0
- package/bin/polycore-runner.mjs +24 -0
- package/package.json +50 -0
- package/src/action.ts +68 -0
- package/src/cli.ts +198 -0
- package/src/config.ts +289 -0
- package/src/connect.ts +363 -0
- package/src/connector.ts +215 -0
- package/src/connectors/firestore.ts +192 -0
- package/src/index.ts +129 -0
- package/src/load-actions.ts +104 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Firestore credentials for one environment of one project: the GCP project
|
|
7
|
+
* plus the path to a **read-only** service-account key
|
|
8
|
+
* (`roles/datastore.viewer`), the Layer-1 credential that the datastore
|
|
9
|
+
* itself guarantees cannot mutate.
|
|
10
|
+
*/
|
|
11
|
+
export const FirestoreEnvironmentSchema = z.object({
|
|
12
|
+
/** GCP project id the environment maps to. */
|
|
13
|
+
projectId: z.string().min(1),
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path to the read-only service-account JSON key for this
|
|
16
|
+
* environment. The key never leaves the runner's host. **Optional**: when
|
|
17
|
+
* omitted, the connector falls back to Application Default Credentials, the
|
|
18
|
+
* idiomatic path when the runner runs inside GCP, where the runtime service
|
|
19
|
+
* account supplies the (read-only) credential and no key file is shipped.
|
|
20
|
+
*/
|
|
21
|
+
serviceAccountKeyPath: z.string().min(1).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type FirestoreEnvironment = z.infer<typeof FirestoreEnvironmentSchema>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One environment of one project, namespaced per connector family so a
|
|
28
|
+
* single environment can carry credentials for several backends (Firestore
|
|
29
|
+
* today; SQL, HTTP APIs like Stripe/RevenueCat, etc. as they land). A
|
|
30
|
+
* built-in connector is advertised for a project only when at least one of
|
|
31
|
+
* the project's environments configures its family.
|
|
32
|
+
*/
|
|
33
|
+
export const EnvironmentConfigSchema = z.object({
|
|
34
|
+
firestore: FirestoreEnvironmentSchema.optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One project (product/system) this runner serves, e.g. a company's two
|
|
41
|
+
* products are two projects. Each project has its own environments and
|
|
42
|
+
* credentials, its own customer-authored actions, and optionally an
|
|
43
|
+
* agent-context document (e.g. its database schema) advertised to the
|
|
44
|
+
* control plane.
|
|
45
|
+
*/
|
|
46
|
+
export const ProjectConfigSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
/** Environment name (`dev`, `staging`, `prod`, …) → its credentials. */
|
|
49
|
+
environments: z
|
|
50
|
+
.record(z.string(), EnvironmentConfigSchema)
|
|
51
|
+
.refine((envs) => Object.keys(envs).length > 0, {
|
|
52
|
+
message: "At least one environment must be configured.",
|
|
53
|
+
}),
|
|
54
|
+
/** Local-CLI default for `runner <capability>` runs. The control plane
|
|
55
|
+
* keeps its own per-project default for front-door asks. */
|
|
56
|
+
defaultEnvironment: z.string().min(1).optional(),
|
|
57
|
+
/**
|
|
58
|
+
* Absolute path to a directory of customer-authored actions for this
|
|
59
|
+
* project (one folder per action, each with an `action.ts`). Loaded and
|
|
60
|
+
* served alongside the built-in connectors.
|
|
61
|
+
*/
|
|
62
|
+
actionsDir: z.string().min(1).optional(),
|
|
63
|
+
/**
|
|
64
|
+
* Secret values for this project's actions, keyed by the names actions
|
|
65
|
+
* declare in `secrets`. Missing keys fall back to the runner-level
|
|
66
|
+
* `secrets`, then `process.env`.
|
|
67
|
+
*/
|
|
68
|
+
secrets: z.record(z.string(), z.string()).optional(),
|
|
69
|
+
/**
|
|
70
|
+
* Agent grounding for this project (markdown/plain text, e.g. the
|
|
71
|
+
* product's database schema). Advertised to the control plane at
|
|
72
|
+
* enrollment and injected into the agent's system prompt when this
|
|
73
|
+
* project is in scope. Use `contextFile` for anything longer than a line.
|
|
74
|
+
*/
|
|
75
|
+
context: z.string().optional(),
|
|
76
|
+
/** Absolute path to a file holding the agent context (wins over `context`). */
|
|
77
|
+
contextFile: z.string().min(1).optional(),
|
|
78
|
+
})
|
|
79
|
+
.refine((p) => p.context === undefined || p.contextFile === undefined, {
|
|
80
|
+
message: 'Set "context" or "contextFile", not both.',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How the runner reaches the control plane. Provisioned at enrollment; the
|
|
87
|
+
* `signingSecret` is shared out-of-band (never sent on the wire) so the
|
|
88
|
+
* runner can verify dispatch envelopes.
|
|
89
|
+
*/
|
|
90
|
+
export const ControlPlaneConfigSchema = z.object({
|
|
91
|
+
/** Control-plane WebSocket URL (`ws://…` locally, `wss://…` in prod). */
|
|
92
|
+
url: z.string().min(1),
|
|
93
|
+
/** This runner's id (`rnr_...` from the enroll response). */
|
|
94
|
+
runnerId: z.string().min(1),
|
|
95
|
+
/** One-time-ish enrollment secret presented on join. */
|
|
96
|
+
joinToken: z.string().min(1),
|
|
97
|
+
/** Shared HMAC secret used to verify dispatch envelopes. */
|
|
98
|
+
signingSecret: z.string().min(1),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export type ControlPlaneConfig = z.infer<typeof ControlPlaneConfigSchema>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The runner's configuration: which projects it serves (each with its own
|
|
105
|
+
* environments/credentials/actions), and optionally how to reach the
|
|
106
|
+
* control plane. One runner serves 1..N projects of ONE organization;
|
|
107
|
+
* data separation across products is per-project config, not per-process.
|
|
108
|
+
*/
|
|
109
|
+
export const RunnerConfigSchema = z.object({
|
|
110
|
+
projects: z
|
|
111
|
+
.record(z.string(), ProjectConfigSchema)
|
|
112
|
+
.refine((projects) => Object.keys(projects).length > 0, {
|
|
113
|
+
message: "At least one project must be configured.",
|
|
114
|
+
}),
|
|
115
|
+
controlPlane: ControlPlaneConfigSchema.optional(),
|
|
116
|
+
/**
|
|
117
|
+
* Runner-level secret fallback for action secrets, shared by every
|
|
118
|
+
* project. Per-project `secrets` win. Missing keys fall back to
|
|
119
|
+
* `process.env`. These never reach the control plane.
|
|
120
|
+
*/
|
|
121
|
+
secrets: z.record(z.string(), z.string()).optional(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type RunnerConfig = z.infer<typeof RunnerConfigSchema>;
|
|
125
|
+
|
|
126
|
+
/** Env var the CLI falls back to when no explicit config path is given. */
|
|
127
|
+
export const RUNNER_CONFIG_ENV = "POLYCORE_RUNNER_CONFIG";
|
|
128
|
+
|
|
129
|
+
/** Parse and validate a runner config from an already-read string. */
|
|
130
|
+
export function parseRunnerConfig(json: string): RunnerConfig {
|
|
131
|
+
const data: unknown = JSON.parse(json);
|
|
132
|
+
return RunnerConfigSchema.parse(data);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read and validate a runner config from a JSON file path. */
|
|
136
|
+
export function loadRunnerConfig(path: string): RunnerConfig {
|
|
137
|
+
return parseRunnerConfig(readFileSync(path, "utf8"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Env var carrying the projects map (JSON) for env-driven config. */
|
|
141
|
+
export const RUNNER_PROJECTS_ENV = "POLYCORE_PROJECTS";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Whether the environment is set up to build a config without a file, used
|
|
145
|
+
* by the CLI to choose between the local file path and the container path.
|
|
146
|
+
*/
|
|
147
|
+
export function hasEnvConfig(source: NodeJS.ProcessEnv): boolean {
|
|
148
|
+
return typeof source[RUNNER_PROJECTS_ENV] === "string";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build a runner config entirely from environment variables, the production
|
|
153
|
+
* path when the runner runs in a container. The non-secret structure
|
|
154
|
+
* (`POLYCORE_PROJECTS`) and the outbound link's secrets
|
|
155
|
+
* (`POLYCORE_JOIN_TOKEN`, `POLYCORE_SIGNING_SECRET`, injected from a secret
|
|
156
|
+
* manager) all arrive as env vars, so nothing is written to disk. The
|
|
157
|
+
* assembled object is validated by the same schema as a file config, so a
|
|
158
|
+
* missing/invalid field fails loud at startup.
|
|
159
|
+
*
|
|
160
|
+
* This is a *pure* function over the passed env map, the CLI entry point is
|
|
161
|
+
* the only place that reads `process.env` and hands it in.
|
|
162
|
+
*/
|
|
163
|
+
export function buildRunnerConfigFromEnv(
|
|
164
|
+
source: NodeJS.ProcessEnv,
|
|
165
|
+
): RunnerConfig {
|
|
166
|
+
const projectsRaw = source[RUNNER_PROJECTS_ENV];
|
|
167
|
+
if (projectsRaw === undefined) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${RUNNER_PROJECTS_ENV} is required to build a config from the environment.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
// Include the control-plane block only when at least one of its vars is set.
|
|
173
|
+
// Omitting it entirely keeps `controlPlane` validly optional (so `runner list`
|
|
174
|
+
// can load actions in a container without enrollment creds), while a *partial*
|
|
175
|
+
// block stays included so the schema pinpoints the missing field.
|
|
176
|
+
const cpUrl = source["POLYCORE_CONTROL_PLANE_URL"];
|
|
177
|
+
const cpRunnerId = source["POLYCORE_RUNNER_ID"];
|
|
178
|
+
const cpJoinToken = source["POLYCORE_JOIN_TOKEN"];
|
|
179
|
+
const cpSigningSecret = source["POLYCORE_SIGNING_SECRET"];
|
|
180
|
+
const hasAnyControlPlane =
|
|
181
|
+
cpUrl !== undefined ||
|
|
182
|
+
cpRunnerId !== undefined ||
|
|
183
|
+
cpJoinToken !== undefined ||
|
|
184
|
+
cpSigningSecret !== undefined;
|
|
185
|
+
|
|
186
|
+
const candidate = {
|
|
187
|
+
projects: parseJsonEnv(projectsRaw, RUNNER_PROJECTS_ENV),
|
|
188
|
+
...(hasAnyControlPlane
|
|
189
|
+
? {
|
|
190
|
+
controlPlane: {
|
|
191
|
+
url: cpUrl,
|
|
192
|
+
runnerId: cpRunnerId,
|
|
193
|
+
joinToken: cpJoinToken,
|
|
194
|
+
signingSecret: cpSigningSecret,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
: {}),
|
|
198
|
+
...(source["POLYCORE_SECRETS"] === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: {
|
|
201
|
+
secrets: parseJsonEnv(source["POLYCORE_SECRETS"], "POLYCORE_SECRETS"),
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
return RunnerConfigSchema.parse(candidate);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseJsonEnv(raw: string, name: string): unknown {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(raw) as unknown;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
throw new Error(`${name} must be valid JSON: ${message}`, { cause: error });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolve the project to run against: an explicit choice, else the only
|
|
218
|
+
* project if there is exactly one. Throws with the valid names listed so
|
|
219
|
+
* callers self-correct.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveProject(
|
|
222
|
+
config: RunnerConfig,
|
|
223
|
+
requested?: string,
|
|
224
|
+
): { slug: string; project: ProjectConfig } {
|
|
225
|
+
const slugs = Object.keys(config.projects);
|
|
226
|
+
const chosen = requested ?? (slugs.length === 1 ? slugs[0] : undefined);
|
|
227
|
+
if (chosen === undefined) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`No project specified. Available: ${slugs.map((s) => `"${s}"`).join(", ")}.`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const project = config.projects[chosen];
|
|
233
|
+
if (!project) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Unknown project "${chosen}". Available: ${slugs
|
|
236
|
+
.map((s) => `"${s}"`)
|
|
237
|
+
.join(", ")}.`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return { slug: chosen, project };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Resolve the environment to run against within a project: an explicit
|
|
245
|
+
* choice, else the project's default, else the only environment if there is
|
|
246
|
+
* exactly one. Throws with the valid names listed so callers self-correct.
|
|
247
|
+
*/
|
|
248
|
+
export function resolveEnvironment(
|
|
249
|
+
project: ProjectConfig,
|
|
250
|
+
requested?: string,
|
|
251
|
+
): { name: string; env: EnvironmentConfig } {
|
|
252
|
+
const names = Object.keys(project.environments);
|
|
253
|
+
const chosen =
|
|
254
|
+
requested ??
|
|
255
|
+
project.defaultEnvironment ??
|
|
256
|
+
(names.length === 1 ? names[0] : undefined);
|
|
257
|
+
|
|
258
|
+
if (chosen === undefined) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`No environment specified and no default set. Available: ${names
|
|
261
|
+
.map((n) => `"${n}"`)
|
|
262
|
+
.join(", ")}.`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const env = project.environments[chosen];
|
|
267
|
+
if (!env) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`Unknown environment "${chosen}". Available: ${names
|
|
270
|
+
.map((n) => `"${n}"`)
|
|
271
|
+
.join(", ")}.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return { name: chosen, env };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The agent-context document a project advertises: the inline `context`
|
|
279
|
+
* string, or the contents of `contextFile`. Read lazily (at enrollment) so
|
|
280
|
+
* a re-connect picks up edits without a restart.
|
|
281
|
+
*/
|
|
282
|
+
export function resolveProjectContext(
|
|
283
|
+
project: ProjectConfig,
|
|
284
|
+
): string | undefined {
|
|
285
|
+
if (project.contextFile !== undefined) {
|
|
286
|
+
return readFileSync(project.contextFile, "utf8");
|
|
287
|
+
}
|
|
288
|
+
return project.context;
|
|
289
|
+
}
|
package/src/connect.ts
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConnectorDescriptor,
|
|
3
|
+
type ControlToRunner,
|
|
4
|
+
type DispatchMessage,
|
|
5
|
+
parseControlToRunner,
|
|
6
|
+
type ProjectAdvertisement,
|
|
7
|
+
PROTOCOL_VERSION,
|
|
8
|
+
type RunnerToControl,
|
|
9
|
+
serializeMessage,
|
|
10
|
+
verifyEnvelope,
|
|
11
|
+
} from "@polycore/protocol";
|
|
12
|
+
import WebSocket from "ws";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
import type { ControlPlaneConfig, RunnerConfig } from "./config.js";
|
|
16
|
+
import { resolveProjectContext } from "./config.js";
|
|
17
|
+
import type { ConnectorRegistry } from "./connector.js";
|
|
18
|
+
|
|
19
|
+
/** Describe a registry's connectors for enrollment (Zod → JSON Schema). */
|
|
20
|
+
export function describeConnectors(
|
|
21
|
+
registry: ConnectorRegistry,
|
|
22
|
+
): ConnectorDescriptor[] {
|
|
23
|
+
return registry.list().map((capability) => ({
|
|
24
|
+
name: capability.name,
|
|
25
|
+
description: capability.description,
|
|
26
|
+
effect: capability.effect,
|
|
27
|
+
kind: capability.kind,
|
|
28
|
+
params: z.toJSONSchema(capability.params),
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the per-project advertisement the join message carries: each
|
|
34
|
+
* project's environments, its capability catalog, and its agent context.
|
|
35
|
+
*/
|
|
36
|
+
export function describeProjects(
|
|
37
|
+
config: RunnerConfig,
|
|
38
|
+
registries: ReadonlyMap<string, ConnectorRegistry>,
|
|
39
|
+
): ProjectAdvertisement[] {
|
|
40
|
+
return Object.entries(config.projects).map(([slug, project]) => {
|
|
41
|
+
const registry = registries.get(slug);
|
|
42
|
+
if (!registry) {
|
|
43
|
+
throw new Error(`No registry built for project "${slug}".`);
|
|
44
|
+
}
|
|
45
|
+
const context = resolveProjectContext(project);
|
|
46
|
+
return {
|
|
47
|
+
slug,
|
|
48
|
+
environments: Object.keys(project.environments),
|
|
49
|
+
connectors: describeConnectors(registry),
|
|
50
|
+
...(context === undefined ? {} : { context }),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Exponential-backoff schedule for re-dialing a dropped control plane. */
|
|
56
|
+
export interface ReconnectOptions {
|
|
57
|
+
/** Delay before the first reconnect attempt. */
|
|
58
|
+
readonly initialDelayMs: number;
|
|
59
|
+
/** Ceiling the backoff is clamped to. */
|
|
60
|
+
readonly maxDelayMs: number;
|
|
61
|
+
/** Multiplier applied per consecutive failed attempt. */
|
|
62
|
+
readonly factor: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
66
|
+
const DEFAULT_PONG_TIMEOUT_MS = 10_000;
|
|
67
|
+
const DEFAULT_RECONNECT: ReconnectOptions = {
|
|
68
|
+
initialDelayMs: 1_000,
|
|
69
|
+
maxDelayMs: 30_000,
|
|
70
|
+
factor: 2,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface RunnerClientOptions {
|
|
74
|
+
readonly controlPlane: ControlPlaneConfig;
|
|
75
|
+
readonly config: RunnerConfig;
|
|
76
|
+
/** One registry per project slug (see `buildProjectRegistries`). */
|
|
77
|
+
readonly registries: ReadonlyMap<string, ConnectorRegistry>;
|
|
78
|
+
readonly log?: (message: string) => void;
|
|
79
|
+
/** Interval between heartbeat pings once enrolled (default 30s). */
|
|
80
|
+
readonly heartbeatIntervalMs?: number;
|
|
81
|
+
/** How long to wait for a pong before declaring the link dead (default 10s). */
|
|
82
|
+
readonly pongTimeoutMs?: number;
|
|
83
|
+
/**
|
|
84
|
+
* Exponential-backoff reconnect schedule, or `false` to disable reconnect
|
|
85
|
+
* entirely (used by tests that drive a single connection by hand).
|
|
86
|
+
*/
|
|
87
|
+
readonly reconnect?: ReconnectOptions | false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The runner's outbound link to the control plane. It dials *out* (no
|
|
92
|
+
* inbound ports), enrolls with its join token, then serves signed dispatch
|
|
93
|
+
* envelopes: verify the signature, run the connector, return the result.
|
|
94
|
+
*
|
|
95
|
+
* The link is self-healing: a WebSocket-level ping/pong heartbeat detects a
|
|
96
|
+
* silently dead control plane, and any drop triggers an exponential-backoff
|
|
97
|
+
* reconnect that re-enrolls from scratch. `connect()` resolves on the *first*
|
|
98
|
+
* successful enrollment; the loop keeps the runner online after that until
|
|
99
|
+
* {@link close} is called or the join is permanently rejected.
|
|
100
|
+
*/
|
|
101
|
+
export class RunnerClient {
|
|
102
|
+
private socket: WebSocket | undefined;
|
|
103
|
+
private readonly log: (message: string) => void;
|
|
104
|
+
|
|
105
|
+
private readonly heartbeatIntervalMs: number;
|
|
106
|
+
private readonly pongTimeoutMs: number;
|
|
107
|
+
private readonly reconnect: ReconnectOptions | false;
|
|
108
|
+
|
|
109
|
+
/** Set by {@link close}; suppresses any further reconnect. */
|
|
110
|
+
private stopped = false;
|
|
111
|
+
/** A `rejected` join is a config error, never worth retrying. */
|
|
112
|
+
private terminal = false;
|
|
113
|
+
private joined = false;
|
|
114
|
+
private reconnectAttempts = 0;
|
|
115
|
+
|
|
116
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
117
|
+
private pongTimer: ReturnType<typeof setTimeout> | undefined;
|
|
118
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
119
|
+
private awaitingPong = false;
|
|
120
|
+
|
|
121
|
+
private connectPromise: Promise<void> | undefined;
|
|
122
|
+
private firstConnectResolve: (() => void) | undefined;
|
|
123
|
+
private firstConnectReject: ((error: Error) => void) | undefined;
|
|
124
|
+
|
|
125
|
+
constructor(private readonly opts: RunnerClientOptions) {
|
|
126
|
+
this.log = opts.log ?? (() => undefined);
|
|
127
|
+
this.heartbeatIntervalMs =
|
|
128
|
+
opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
129
|
+
this.pongTimeoutMs = opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
|
|
130
|
+
this.reconnect = opts.reconnect ?? DEFAULT_RECONNECT;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Dial the control plane and enroll. Resolves once enrollment first
|
|
135
|
+
* succeeds; rejects only on a permanent rejection (bad token / unknown
|
|
136
|
+
* runner). Transient network failures are retried with backoff, so a
|
|
137
|
+
* control plane that is merely down at startup is awaited, not failed.
|
|
138
|
+
*/
|
|
139
|
+
public connect(): Promise<void> {
|
|
140
|
+
if (this.connectPromise) return this.connectPromise;
|
|
141
|
+
this.connectPromise = new Promise<void>((resolve, reject) => {
|
|
142
|
+
this.firstConnectResolve = resolve;
|
|
143
|
+
this.firstConnectReject = reject;
|
|
144
|
+
});
|
|
145
|
+
this.openConnection();
|
|
146
|
+
return this.connectPromise;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
public close(): void {
|
|
150
|
+
this.stopped = true;
|
|
151
|
+
this.stopHeartbeat();
|
|
152
|
+
if (this.reconnectTimer) {
|
|
153
|
+
clearTimeout(this.reconnectTimer);
|
|
154
|
+
this.reconnectTimer = undefined;
|
|
155
|
+
}
|
|
156
|
+
this.socket?.close();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private openConnection(): void {
|
|
160
|
+
if (this.stopped || this.terminal) return;
|
|
161
|
+
const { url } = this.opts.controlPlane;
|
|
162
|
+
const socket = new WebSocket(url);
|
|
163
|
+
this.socket = socket;
|
|
164
|
+
this.joined = false;
|
|
165
|
+
|
|
166
|
+
socket.on("open", () => {
|
|
167
|
+
this.log(`Connected to ${url}; enrolling…`);
|
|
168
|
+
this.send({
|
|
169
|
+
type: "join",
|
|
170
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
171
|
+
runnerId: this.opts.controlPlane.runnerId,
|
|
172
|
+
joinToken: this.opts.controlPlane.joinToken,
|
|
173
|
+
projects: describeProjects(this.opts.config, this.opts.registries),
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
socket.on("message", (data: WebSocket.RawData) => {
|
|
178
|
+
this.onMessage(socket, data);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
socket.on("error", (error) => {
|
|
182
|
+
// ws always emits `close` after `error`; reconnect is driven there so
|
|
183
|
+
// we don't double-schedule. Just surface the cause.
|
|
184
|
+
this.log(`Socket error: ${describe(error)}`);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
socket.on("close", () => {
|
|
188
|
+
this.onClose();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private onMessage(socket: WebSocket, data: WebSocket.RawData): void {
|
|
193
|
+
let message: ControlToRunner;
|
|
194
|
+
try {
|
|
195
|
+
message = parseControlToRunner(rawToString(data));
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.log(`Ignoring unparseable message: ${describe(error)}`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (message.type === "joined") {
|
|
201
|
+
this.joined = true;
|
|
202
|
+
this.reconnectAttempts = 0;
|
|
203
|
+
this.log(`Enrolled (session ${message.sessionId}).`);
|
|
204
|
+
this.startHeartbeat();
|
|
205
|
+
this.firstConnectResolve?.();
|
|
206
|
+
this.firstConnectResolve = undefined;
|
|
207
|
+
this.firstConnectReject = undefined;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (message.type === "rejected") {
|
|
211
|
+
this.terminal = true;
|
|
212
|
+
const error = new Error(`Control plane rejected join: ${message.reason}`);
|
|
213
|
+
this.firstConnectReject?.(error);
|
|
214
|
+
this.firstConnectReject = undefined;
|
|
215
|
+
this.firstConnectResolve = undefined;
|
|
216
|
+
socket.close();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (message.type === "pong") {
|
|
220
|
+
this.onPong();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
void this.handleDispatch(message);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private onClose(): void {
|
|
227
|
+
this.stopHeartbeat();
|
|
228
|
+
if (this.joined) this.log("Connection closed.");
|
|
229
|
+
this.joined = false;
|
|
230
|
+
if (this.stopped || this.terminal) return;
|
|
231
|
+
this.scheduleReconnect();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private scheduleReconnect(): void {
|
|
235
|
+
if (this.reconnect === false) return;
|
|
236
|
+
const { initialDelayMs, maxDelayMs, factor } = this.reconnect;
|
|
237
|
+
const delay = Math.min(
|
|
238
|
+
maxDelayMs,
|
|
239
|
+
initialDelayMs * factor ** this.reconnectAttempts,
|
|
240
|
+
);
|
|
241
|
+
this.reconnectAttempts += 1;
|
|
242
|
+
this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})…`);
|
|
243
|
+
this.reconnectTimer = setTimeout(() => {
|
|
244
|
+
this.reconnectTimer = undefined;
|
|
245
|
+
this.openConnection();
|
|
246
|
+
}, delay);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private startHeartbeat(): void {
|
|
250
|
+
this.stopHeartbeat();
|
|
251
|
+
this.heartbeatTimer = setInterval(() => {
|
|
252
|
+
const socket = this.socket;
|
|
253
|
+
if (socket?.readyState !== WebSocket.OPEN) return;
|
|
254
|
+
// A still-outstanding ping means the previous pong never came back:
|
|
255
|
+
// the link is silently dead. Force it closed so reconnect kicks in.
|
|
256
|
+
if (this.awaitingPong) {
|
|
257
|
+
this.log("Heartbeat timed out; terminating dead connection.");
|
|
258
|
+
socket.terminate();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.awaitingPong = true;
|
|
262
|
+
this.pongTimer = setTimeout(() => {
|
|
263
|
+
if (!this.awaitingPong) return;
|
|
264
|
+
this.log("Heartbeat timed out; terminating dead connection.");
|
|
265
|
+
this.socket?.terminate();
|
|
266
|
+
}, this.pongTimeoutMs);
|
|
267
|
+
// Application-level ping (a real message), NOT the WebSocket protocol's
|
|
268
|
+
// ping frame: a proxy in front of the control plane (Cloud Run) answers
|
|
269
|
+
// protocol pings itself, masking a dead backend. Only a message the
|
|
270
|
+
// control plane application must echo proves the link is truly alive.
|
|
271
|
+
this.send({ type: "ping" });
|
|
272
|
+
}, this.heartbeatIntervalMs);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private onPong(): void {
|
|
276
|
+
this.awaitingPong = false;
|
|
277
|
+
if (this.pongTimer) {
|
|
278
|
+
clearTimeout(this.pongTimer);
|
|
279
|
+
this.pongTimer = undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private stopHeartbeat(): void {
|
|
284
|
+
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
|
285
|
+
if (this.pongTimer) clearTimeout(this.pongTimer);
|
|
286
|
+
this.heartbeatTimer = undefined;
|
|
287
|
+
this.pongTimer = undefined;
|
|
288
|
+
this.awaitingPong = false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private async handleDispatch(message: DispatchMessage): Promise<void> {
|
|
292
|
+
const { envelope, sig } = message;
|
|
293
|
+
if (!verifyEnvelope(envelope, sig, this.opts.controlPlane.signingSecret)) {
|
|
294
|
+
this.log(`Rejected dispatch ${envelope.id}: bad signature.`);
|
|
295
|
+
this.send({
|
|
296
|
+
type: "result",
|
|
297
|
+
id: envelope.id,
|
|
298
|
+
ok: false,
|
|
299
|
+
error: { message: "envelope signature verification failed" },
|
|
300
|
+
});
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const registry = this.opts.registries.get(envelope.project);
|
|
305
|
+
const project = this.opts.config.projects[envelope.project];
|
|
306
|
+
if (!registry || !project) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Unknown project "${envelope.project}". Configured: ${[
|
|
309
|
+
...this.opts.registries.keys(),
|
|
310
|
+
]
|
|
311
|
+
.map((s) => `"${s}"`)
|
|
312
|
+
.join(", ")}.`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
const env = project.environments[envelope.environment];
|
|
316
|
+
if (!env) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Unknown environment "${envelope.environment}" for project "${envelope.project}". ` +
|
|
319
|
+
`Configured: ${Object.keys(project.environments)
|
|
320
|
+
.map((n) => `"${n}"`)
|
|
321
|
+
.join(", ")}.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
const dispatched = await registry.dispatch(
|
|
325
|
+
envelope.capability,
|
|
326
|
+
envelope.args,
|
|
327
|
+
{
|
|
328
|
+
project: envelope.project,
|
|
329
|
+
environment: envelope.environment,
|
|
330
|
+
env,
|
|
331
|
+
log: (m) => this.log(m),
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
this.send({
|
|
335
|
+
type: "result",
|
|
336
|
+
id: envelope.id,
|
|
337
|
+
ok: true,
|
|
338
|
+
result: dispatched.result,
|
|
339
|
+
});
|
|
340
|
+
} catch (error) {
|
|
341
|
+
this.send({
|
|
342
|
+
type: "result",
|
|
343
|
+
id: envelope.id,
|
|
344
|
+
ok: false,
|
|
345
|
+
error: { message: describe(error) },
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private send(message: RunnerToControl): void {
|
|
351
|
+
this.socket?.send(serializeMessage(message));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function describe(error: unknown): string {
|
|
356
|
+
return error instanceof Error ? error.message : String(error);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function rawToString(data: WebSocket.RawData): string {
|
|
360
|
+
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
|
361
|
+
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
|
|
362
|
+
return Buffer.from(data).toString("utf8");
|
|
363
|
+
}
|