@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/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# @polycore/runner
|
|
2
|
+
|
|
3
|
+
The Polycore **runner** executes governed, read-only-constrained connectors
|
|
4
|
+
against a customer's datastores. It runs in the **customer's own
|
|
5
|
+
infrastructure**, holds scoped credentials read from the customer's own secret
|
|
6
|
+
store, and connects **outbound** to the Polycore control plane (a persistent
|
|
7
|
+
WebSocket it dials out; no inbound port).
|
|
8
|
+
|
|
9
|
+
One runner belongs to **one organization** and serves **one or more projects**
|
|
10
|
+
(the customer's products/systems). Each project carries its own environments,
|
|
11
|
+
credentials, customer-authored actions, and optional agent-context document;
|
|
12
|
+
the runner advertises all of it per project when it joins the control plane.
|
|
13
|
+
Whether a customer runs one runner for all products or one per product is
|
|
14
|
+
purely a deployment choice; the config shape is the same.
|
|
15
|
+
|
|
16
|
+
It ships the **Firestore read connectors**, loads customer-authored **actions**,
|
|
17
|
+
and can be driven either by the control plane (`runner connect`) or by hand via
|
|
18
|
+
the local CLI harness. See `internal-docs/architecture.md` and
|
|
19
|
+
`internal-docs/mvp-plan.md`.
|
|
20
|
+
|
|
21
|
+
## Connectors
|
|
22
|
+
|
|
23
|
+
A connector is a generic, read-only transport: the caller supplies the
|
|
24
|
+
query/path at request time. All connectors are `read` (Lane 1): they exist
|
|
25
|
+
because they are mutation-incapable, which is what lets them run with no
|
|
26
|
+
approval.
|
|
27
|
+
|
|
28
|
+
| Capability | Args | Returns |
|
|
29
|
+
| --------------------------- | ------------------------------------------ | ---------------------------------- |
|
|
30
|
+
| `firestore.query` | `{ collection, where?, orderBy?, limit? }` | array of `{ id, data }` |
|
|
31
|
+
| `firestore.get` | `{ collection, id }` | `{ id, data }` or `null` |
|
|
32
|
+
| `firestore.count` | `{ collection, where? }` | `number` (server-side aggregation) |
|
|
33
|
+
| `firestore.listCollections` | `{}` | array of collection ids |
|
|
34
|
+
|
|
35
|
+
`where` clauses are `{ field, op, value }` where `op` is a Firestore filter
|
|
36
|
+
operator (`==`, `!=`, `<`, `<=`, `>`, `>=`, `array-contains`,
|
|
37
|
+
`array-contains-any`, `in`, `not-in`).
|
|
38
|
+
|
|
39
|
+
## Read-only safety (two layers)
|
|
40
|
+
|
|
41
|
+
- **Layer 1: IAM.** The credential carries `roles/datastore.viewer`; GCP
|
|
42
|
+
rejects any write at the API boundary. (The Admin SDK bypasses Firestore
|
|
43
|
+
Security Rules, so the IAM role, not rules, is what enforces read-only.)
|
|
44
|
+
This is true whether the credential is a downloaded key (dev) or the
|
|
45
|
+
runtime service account picked up via Application Default Credentials (prod).
|
|
46
|
+
- **Layer 2: this code.** The connectors only ever call read methods.
|
|
47
|
+
|
|
48
|
+
## Setup: create a read-only service account (per environment)
|
|
49
|
+
|
|
50
|
+
With one Firebase project per environment, create a read-only SA in each and
|
|
51
|
+
download its key. Example for `dev` (replace `PROJECT_ID` with your own):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
PROJECT_ID=my-app-dev
|
|
55
|
+
|
|
56
|
+
gcloud iam service-accounts create polycore-readonly \
|
|
57
|
+
--project "$PROJECT_ID" \
|
|
58
|
+
--display-name "Polycore read-only runner"
|
|
59
|
+
|
|
60
|
+
gcloud projects add-iam-policy-binding "$PROJECT_ID" \
|
|
61
|
+
--member "serviceAccount:polycore-readonly@$PROJECT_ID.iam.gserviceaccount.com" \
|
|
62
|
+
--role "roles/datastore.viewer"
|
|
63
|
+
|
|
64
|
+
gcloud iam service-accounts keys create ./readonly-dev.json \
|
|
65
|
+
--project "$PROJECT_ID" \
|
|
66
|
+
--iam-account "polycore-readonly@$PROJECT_ID.iam.gserviceaccount.com"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> Keep the key file out of git. When the runner later runs inside GCP, prefer
|
|
70
|
+
> workload identity over a downloaded key.
|
|
71
|
+
|
|
72
|
+
## Config
|
|
73
|
+
|
|
74
|
+
Create a `runner.config.json` (path passed via `--config` or the
|
|
75
|
+
`POLYCORE_RUNNER_CONFIG` env var). The top level is a **projects map**: one
|
|
76
|
+
entry per product/system this runner serves. Environments are namespaced per
|
|
77
|
+
connector family (`firestore` today; SQL/HTTP families as they land), so one
|
|
78
|
+
environment can carry credentials for several backends:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"projects": {
|
|
83
|
+
"alpha": {
|
|
84
|
+
"defaultEnvironment": "dev",
|
|
85
|
+
"environments": {
|
|
86
|
+
"dev": {
|
|
87
|
+
"firestore": {
|
|
88
|
+
"projectId": "alpha-dev",
|
|
89
|
+
"serviceAccountKeyPath": "/absolute/path/to/alpha-readonly-dev.json"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"prod": {
|
|
93
|
+
"firestore": { "projectId": "alpha-prod" }
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"actionsDir": "/absolute/path/to/alpha/polycore-actions",
|
|
97
|
+
"contextFile": "/absolute/path/to/alpha/schema.md"
|
|
98
|
+
},
|
|
99
|
+
"beta": {
|
|
100
|
+
"environments": {
|
|
101
|
+
"prod": {
|
|
102
|
+
"firestore": { "projectId": "beta-prod" }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"controlPlane": {
|
|
108
|
+
"url": "wss://cp.example.com/runner",
|
|
109
|
+
"runnerId": "<uuid from enrollment>",
|
|
110
|
+
"joinToken": "<from enrollment>",
|
|
111
|
+
"signingSecret": "<from enrollment>"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Per-project extras:
|
|
117
|
+
|
|
118
|
+
- `actionsDir`: customer-authored actions for that project, loaded alongside
|
|
119
|
+
the built-in connectors.
|
|
120
|
+
- `secrets`: per-project action secret values (falls back to the runner-level
|
|
121
|
+
`secrets` map, then `process.env`). These never reach the control plane.
|
|
122
|
+
- `context` / `contextFile`: an agent-grounding document (e.g. the product's
|
|
123
|
+
database schema). Advertised at join and injected into the agent's system
|
|
124
|
+
prompt when this project is in scope. Re-read on every (re)connect.
|
|
125
|
+
|
|
126
|
+
The `controlPlane` block is optional; without it the local harness still works.
|
|
127
|
+
|
|
128
|
+
## Run the harness
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
# list available capabilities across all projects
|
|
132
|
+
pnpm --filter @polycore/runner runner list --config ./runner.config.json
|
|
133
|
+
|
|
134
|
+
# "how many users?" against alpha's dev
|
|
135
|
+
pnpm --filter @polycore/runner runner firestore.count \
|
|
136
|
+
--project alpha --env dev --args '{"collection":"users"}' \
|
|
137
|
+
--config ./runner.config.json
|
|
138
|
+
|
|
139
|
+
# the first five users (sole project → --project can be omitted)
|
|
140
|
+
pnpm --filter @polycore/runner runner firestore.query \
|
|
141
|
+
--env dev --args '{"collection":"users","limit":5}' \
|
|
142
|
+
--config ./runner.config.json
|
|
143
|
+
|
|
144
|
+
# hold the outbound control-plane link (production mode)
|
|
145
|
+
pnpm --filter @polycore/runner runner connect --config ./runner.config.json
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Logs go to stderr; the JSON result goes to stdout (pipeable).
|
|
149
|
+
|
|
150
|
+
> `serviceAccountKeyPath` is **optional**. Omit it to use Application Default
|
|
151
|
+
> Credentials: the idiomatic path when the runner runs inside GCP, where the
|
|
152
|
+
> runtime service account supplies the (read-only) credential and no key file
|
|
153
|
+
> ships. Provide a key path only for local/off-GCP use.
|
|
154
|
+
|
|
155
|
+
## Deploy to Cloud Run
|
|
156
|
+
|
|
157
|
+
The runner ships as a container that dials the control plane outbound, so it
|
|
158
|
+
needs **no inbound port**: `min-instances=1` keeps the link warm. The image is
|
|
159
|
+
built from the repo root; config and secrets arrive entirely via env vars, so
|
|
160
|
+
nothing is baked in.
|
|
161
|
+
|
|
162
|
+
### 1. Build and push the image
|
|
163
|
+
|
|
164
|
+
Cloud Run runs `linux/amd64`:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
docker build --platform linux/amd64 \
|
|
168
|
+
-f packages/runner/Dockerfile \
|
|
169
|
+
-t REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest .
|
|
170
|
+
|
|
171
|
+
docker push REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 2. Give the runtime service account read-only datastore access
|
|
175
|
+
|
|
176
|
+
In prod there is **no key file**: the runner uses Application Default
|
|
177
|
+
Credentials, i.e. the Cloud Run runtime service account. Grant it the same
|
|
178
|
+
read-only role the key would have carried:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
gcloud projects add-iam-policy-binding PROJECT_ID \
|
|
182
|
+
--member "serviceAccount:RUNTIME_SA@PROJECT_ID.iam.gserviceaccount.com" \
|
|
183
|
+
--role "roles/datastore.viewer"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### 3. Store the outbound-link secrets in Secret Manager
|
|
187
|
+
|
|
188
|
+
The join token and signing secret are shared at enrollment and must never be
|
|
189
|
+
baked into the image:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
printf '%s' "$JOIN_TOKEN" | gcloud secrets create polycore-join-token --data-file=-
|
|
193
|
+
printf '%s' "$SIGNING_SECRET" | gcloud secrets create polycore-signing-secret --data-file=-
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 4. Deploy
|
|
197
|
+
|
|
198
|
+
Non-secret config is plain env vars; the two secrets come from Secret Manager.
|
|
199
|
+
`POLYCORE_PROJECTS` is the projects map (same shape as the config file's
|
|
200
|
+
`projects` field). With ADC each environment needs only its `projectId` (no
|
|
201
|
+
`serviceAccountKeyPath`):
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
gcloud run deploy polycore-runner \
|
|
205
|
+
--image REGION-docker.pkg.dev/PROJECT_ID/polycore/runner:latest \
|
|
206
|
+
--no-allow-unauthenticated \
|
|
207
|
+
--min-instances 1 --max-instances 1 --no-cpu-throttling \
|
|
208
|
+
--service-account RUNTIME_SA@PROJECT_ID.iam.gserviceaccount.com \
|
|
209
|
+
--set-env-vars 'POLYCORE_CONTROL_PLANE_URL=wss://control.example.com/runner,POLYCORE_RUNNER_ID=<uuid-from-enrollment>' \
|
|
210
|
+
--set-env-vars 'POLYCORE_PROJECTS={"alpha":{"environments":{"prod":{"firestore":{"projectId":"PROJECT_ID"}}}}}' \
|
|
211
|
+
--set-secrets 'POLYCORE_JOIN_TOKEN=polycore-join-token:latest,POLYCORE_SIGNING_SECRET=polycore-signing-secret:latest'
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
> **Cloud Run caveats (learned in prod).**
|
|
215
|
+
>
|
|
216
|
+
> - **The URL needs the `/runner` WS path**: `wss://<host>/runner`, not just the
|
|
217
|
+
> host. The control plane only upgrades WebSocket connections on that path.
|
|
218
|
+
> - **`--no-cpu-throttling` is mandatory.** The runner is an outbound-only worker
|
|
219
|
+
> that never serves requests, so by default Cloud Run would throttle its CPU to
|
|
220
|
+
> ~0 between (non-existent) requests and freeze its heartbeat/reconnect timers.
|
|
221
|
+
> CPU-always-allocated keeps the link and the self-healing alive. `max-instances 1`
|
|
222
|
+
> keeps the single WS pinned to one instance.
|
|
223
|
+
> - **A request-driven host needs the container to listen on `$PORT`.** The
|
|
224
|
+
> `connect` command starts a tiny liveness HTTP server when `PORT` is set (it is
|
|
225
|
+
> a no-op locally) purely so Cloud Run's startup probe passes; the real work is
|
|
226
|
+
> still the outbound WS. It opens the port **before** loading/compiling actions,
|
|
227
|
+
> so a cold boot is healthy in <1s. If the port only opened after the
|
|
228
|
+
> (esbuild-backed) action load, a slow boot would blow the startup-probe
|
|
229
|
+
> deadline and Cloud Run would kill + restart the instance in a ~4-minute loop.
|
|
230
|
+
> - **Redeploy ordering: control plane first, then bounce the runner.** A live
|
|
231
|
+
> runner's long-lived WS pins it to the control-plane instance it dialed, and
|
|
232
|
+
> that open connection keeps the old (draining) instance alive, so after a
|
|
233
|
+
> control-plane redeploy the runner keeps talking to the stale instance while
|
|
234
|
+
> new HTTP traffic hits the fresh revision's empty hub. Roll a new runner
|
|
235
|
+
> revision (any config change, e.g. bump a nonce env var) after deploying the
|
|
236
|
+
> control plane so it reconnects to the current revision.
|
|
237
|
+
|
|
238
|
+
| Env var | Required | Notes |
|
|
239
|
+
| ---------------------------- | :------: | ------------------------------------------------------------------------- |
|
|
240
|
+
| `POLYCORE_PROJECTS` | âś“ | JSON projects map (same shape as the config file's `projects`) |
|
|
241
|
+
| `POLYCORE_CONTROL_PLANE_URL` | ✓ | `wss://…` control-plane WebSocket |
|
|
242
|
+
| `POLYCORE_RUNNER_ID` | âś“ | This runner's enrolled id (a uuid) |
|
|
243
|
+
| `POLYCORE_JOIN_TOKEN` | âś“ | Enrollment secret (Secret Manager) |
|
|
244
|
+
| `POLYCORE_SIGNING_SECRET` | âś“ | HMAC secret to verify dispatch envelopes (Secret Manager) |
|
|
245
|
+
| `POLYCORE_SECRETS` | | JSON map of runner-level action secrets (per-project `secrets` still win) |
|
|
246
|
+
|
|
247
|
+
When `POLYCORE_PROJECTS` is set and no `--config` file is given, the runner
|
|
248
|
+
builds its config from these vars; a missing/invalid one fails loud at startup.
|
|
249
|
+
Per-project settings that reference local files (`actionsDir`, `contextFile`,
|
|
250
|
+
`serviceAccountKeyPath`) point at paths baked into or mounted on the container.
|
|
251
|
+
|
|
252
|
+
## Tests
|
|
253
|
+
|
|
254
|
+
```sh
|
|
255
|
+
pnpm --filter @polycore/runner test
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Unit tests cover the registry's validate-then-dispatch path, the read-only
|
|
259
|
+
invariant, and config parsing. Live-Firestore tests are intentionally not part
|
|
260
|
+
of the default run (they require real credentials); use the harness above to
|
|
261
|
+
verify against a live project.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Launcher for the runner CLI. The runner is shipped as TypeScript source and
|
|
3
|
+
// executed by tsx (it must also load customer-authored `.ts` actions at
|
|
4
|
+
// runtime), so this thin bin re-execs Node with tsx registered as a loader and
|
|
5
|
+
// hands off to `src/cli.ts`. Consumers run `polycore-runner connect` (or drive
|
|
6
|
+
// the local harness, e.g. `polycore-runner list`).
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
const cli = fileURLToPath(new URL("../src/cli.ts", import.meta.url));
|
|
11
|
+
|
|
12
|
+
const child = spawn(
|
|
13
|
+
process.execPath,
|
|
14
|
+
["--import", "tsx", cli, ...process.argv.slice(2)],
|
|
15
|
+
{ stdio: "inherit" },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
child.on("exit", (code, signal) => {
|
|
19
|
+
if (signal !== null) {
|
|
20
|
+
process.kill(process.pid, signal);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
process.exit(code ?? 0);
|
|
24
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polycore/runner",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Polycore runner: executes governed, read-only-constrained connectors against a customer's datastores. Runs in the customer's own infrastructure; holds scoped credentials and connects outbound to the control plane.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts",
|
|
13
|
+
"default": "./src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"polycore-runner": "./bin/polycore-runner.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"bin",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"firebase-admin": "^14.0.0",
|
|
30
|
+
"tsx": "4.21.0",
|
|
31
|
+
"ws": "8.18.3",
|
|
32
|
+
"zod": "4.4.3",
|
|
33
|
+
"@polycore/protocol": "^0.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "25.6.2",
|
|
37
|
+
"@types/ws": "8.18.1",
|
|
38
|
+
"typescript": "6.0.3",
|
|
39
|
+
"vitest": "4.1.6"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"runner": "tsx src/cli.ts",
|
|
46
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/action.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Blessed **actions** are the second execution lane: durable, customer-authored
|
|
5
|
+
* operations (e.g. "customer 360", "refund user") that live in the customer's
|
|
6
|
+
* own repo and are loaded by the runner. Unlike connectors — generic,
|
|
7
|
+
* read-only transports that ship inside the runner — an action is a named,
|
|
8
|
+
* parameterized unit of work with its own input/output contract.
|
|
9
|
+
*
|
|
10
|
+
* This is the same authoring shape as the desktop SDK's `defineAction`, minus
|
|
11
|
+
* the UI/page concern: a Slack/agent caller consumes the structured `output`,
|
|
12
|
+
* not a React page. An action reaches into the customer's own backend (their
|
|
13
|
+
* Firestore, Stripe, internal APIs) using `secrets` the runner resolves from
|
|
14
|
+
* its environment — the credential never leaves the runner's host.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type ActionEffect = "read" | "mutate";
|
|
18
|
+
|
|
19
|
+
/** Human-facing progress sink, mirroring the desktop SDK's `ctx.log`. */
|
|
20
|
+
export interface ActionLogger {
|
|
21
|
+
info(message: string): void;
|
|
22
|
+
warn(message: string): void;
|
|
23
|
+
error(message: string): void;
|
|
24
|
+
success(message: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActionRunContext<Input> {
|
|
28
|
+
/** Caller-supplied, schema-validated input. */
|
|
29
|
+
readonly input: Input;
|
|
30
|
+
/** Declared secrets, resolved by the runner from its environment. */
|
|
31
|
+
readonly secrets: Readonly<Record<string, string>>;
|
|
32
|
+
/** The environment to run against (e.g. "dev"), from the dispatch. */
|
|
33
|
+
readonly environment: string;
|
|
34
|
+
readonly log: ActionLogger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Action<Input = unknown, Output = unknown> {
|
|
38
|
+
readonly title: string;
|
|
39
|
+
readonly description: string;
|
|
40
|
+
readonly tags?: readonly string[];
|
|
41
|
+
/**
|
|
42
|
+
* Hazard class. Defaults to `mutate` — the safe default, since an
|
|
43
|
+
* unclassified operation must be treated as dangerous and routed through
|
|
44
|
+
* the approval gate. Declare `read` for idempotent, side-effect-free work.
|
|
45
|
+
*/
|
|
46
|
+
readonly effect?: ActionEffect;
|
|
47
|
+
readonly input: z.ZodType<Input>;
|
|
48
|
+
readonly output: z.ZodType<Output>;
|
|
49
|
+
/** Secret keys this action needs, mapped to a human description. */
|
|
50
|
+
readonly secrets?: Readonly<Record<string, string>>;
|
|
51
|
+
run(ctx: ActionRunContext<Input>): Promise<Output> | Output;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Identity helper that pins an action's I/O types. Authored modules
|
|
56
|
+
* `export default defineAction({ … })`; the loader injects the folder-path id.
|
|
57
|
+
*/
|
|
58
|
+
export function defineAction<Input, Output>(
|
|
59
|
+
spec: Action<Input, Output>,
|
|
60
|
+
): Action<Input, Output> {
|
|
61
|
+
return spec;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A loaded action plus the folder-path id the loader derived for it. */
|
|
65
|
+
export interface LoadedAction {
|
|
66
|
+
readonly id: string;
|
|
67
|
+
readonly action: Action;
|
|
68
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildRunnerConfigFromEnv,
|
|
6
|
+
hasEnvConfig,
|
|
7
|
+
loadRunnerConfig,
|
|
8
|
+
resolveEnvironment,
|
|
9
|
+
resolveProject,
|
|
10
|
+
RUNNER_CONFIG_ENV,
|
|
11
|
+
RUNNER_PROJECTS_ENV,
|
|
12
|
+
type RunnerConfig,
|
|
13
|
+
} from "./config.js";
|
|
14
|
+
import { RunnerClient } from "./connect.js";
|
|
15
|
+
import { buildProjectRegistries } from "./index.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Local harness for driving connectors by hand, the dogfood entry point
|
|
19
|
+
* before the control-plane connection exists. Logs go to stderr; the JSON
|
|
20
|
+
* result goes to stdout, so output is pipeable.
|
|
21
|
+
*
|
|
22
|
+
* pnpm --filter @polycore/runner runner list --config ./runner.config.json
|
|
23
|
+
* pnpm --filter @polycore/runner runner firestore.count \
|
|
24
|
+
* --project alpha --env dev --args '{"collection":"users"}' \
|
|
25
|
+
* --config ./runner.config.json
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
interface ParsedArgs {
|
|
29
|
+
readonly positional: string[];
|
|
30
|
+
readonly flags: Map<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
34
|
+
const positional: string[] = [];
|
|
35
|
+
const flags = new Map<string, string>();
|
|
36
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
37
|
+
const token = argv[i];
|
|
38
|
+
if (token === undefined) continue;
|
|
39
|
+
if (!token.startsWith("--")) {
|
|
40
|
+
positional.push(token);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const eq = token.indexOf("=");
|
|
44
|
+
if (eq !== -1) {
|
|
45
|
+
flags.set(token.slice(2, eq), token.slice(eq + 1));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const key = token.slice(2);
|
|
49
|
+
const next = argv[i + 1];
|
|
50
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
51
|
+
flags.set(key, next);
|
|
52
|
+
i += 1;
|
|
53
|
+
} else {
|
|
54
|
+
flags.set(key, "true");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { positional, flags };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printUsage(): void {
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
[
|
|
63
|
+
"polycore runner, local connector harness",
|
|
64
|
+
"",
|
|
65
|
+
"Usage:",
|
|
66
|
+
" runner list [--config <path>]",
|
|
67
|
+
" runner connect [--config <path>]",
|
|
68
|
+
" runner <capability> [--project <slug>] [--env <name>] [--args <json>] [--config <path>]",
|
|
69
|
+
"",
|
|
70
|
+
"Flags:",
|
|
71
|
+
" --project project to run against (default: the sole configured project)",
|
|
72
|
+
" --env environment to run against (default: project default or sole env)",
|
|
73
|
+
" --args JSON object of connector arguments (default: {})",
|
|
74
|
+
` --config path to runner config (default: $${RUNNER_CONFIG_ENV})`,
|
|
75
|
+
"",
|
|
76
|
+
"Examples:",
|
|
77
|
+
' runner firestore.count --project alpha --env dev --args \'{"collection":"users"}\'',
|
|
78
|
+
' runner firestore.query --env dev --args \'{"collection":"users","limit":5}\'',
|
|
79
|
+
"",
|
|
80
|
+
].join("\n"),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pick the config source: an explicit file (flag or `POLYCORE_RUNNER_CONFIG`)
|
|
86
|
+
* for local use, else an env-driven config when the container path is set
|
|
87
|
+
* (`POLYCORE_PROJECTS` present).
|
|
88
|
+
*/
|
|
89
|
+
function resolveConfig(configFlag: string | undefined): RunnerConfig {
|
|
90
|
+
const configPath = configFlag ?? process.env[RUNNER_CONFIG_ENV];
|
|
91
|
+
if (configPath !== undefined) return loadRunnerConfig(configPath);
|
|
92
|
+
if (hasEnvConfig(process.env)) return buildRunnerConfigFromEnv(process.env);
|
|
93
|
+
throw new Error(
|
|
94
|
+
`No runner config. Pass --config <path>, set ${RUNNER_CONFIG_ENV}, ` +
|
|
95
|
+
`or provide an env-driven config (${RUNNER_PROJECTS_ENV} + control-plane vars).`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Request-driven hosts (Cloud Run, etc.) require the container to listen on
|
|
101
|
+
* `$PORT` and pass a startup probe before routing it traffic. The runner is an
|
|
102
|
+
* outbound-only WebSocket worker that never serves requests, so we run a tiny
|
|
103
|
+
* liveness server alongside the connection purely to satisfy the probe. No-op
|
|
104
|
+
* when `PORT` is unset, local CLI use is unaffected.
|
|
105
|
+
*/
|
|
106
|
+
function maybeStartHealthServer(log: (message: string) => void): void {
|
|
107
|
+
const port = process.env["PORT"];
|
|
108
|
+
if (port === undefined || port.length === 0) return;
|
|
109
|
+
const server = createServer((_req, res) => {
|
|
110
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
111
|
+
res.end(JSON.stringify({ ok: true, role: "runner" }));
|
|
112
|
+
});
|
|
113
|
+
server.listen(Number(port), () => {
|
|
114
|
+
log(`Liveness server listening on :${port}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function main(): Promise<void> {
|
|
119
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
120
|
+
const command = positional[0];
|
|
121
|
+
|
|
122
|
+
if (command === undefined || command === "help") {
|
|
123
|
+
printUsage();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// For the long-running `connect` worker, open the liveness port *before* the
|
|
128
|
+
// (potentially slow) action compilation in `buildProjectRegistries`, so a
|
|
129
|
+
// request-driven host's startup probe passes in <1s regardless of how long
|
|
130
|
+
// loading takes. Otherwise a cold boot can exceed the probe deadline and get
|
|
131
|
+
// the instance killed + restarted in a loop.
|
|
132
|
+
if (command === "connect") {
|
|
133
|
+
maybeStartHealthServer((message) => process.stderr.write(`${message}\n`));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const config = resolveConfig(flags.get("config"));
|
|
137
|
+
const registries = await buildProjectRegistries(config);
|
|
138
|
+
|
|
139
|
+
if (command === "list") {
|
|
140
|
+
for (const [slug, registry] of registries) {
|
|
141
|
+
for (const capability of registry.list()) {
|
|
142
|
+
process.stdout.write(
|
|
143
|
+
`[${slug}] ${capability.name} [${capability.kind}/${capability.effect}] ${capability.description}\n`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (command === "connect") {
|
|
151
|
+
const { controlPlane } = config;
|
|
152
|
+
if (controlPlane === undefined) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
'No "controlPlane" block in the runner config; cannot connect.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const client = new RunnerClient({
|
|
158
|
+
controlPlane,
|
|
159
|
+
config,
|
|
160
|
+
registries,
|
|
161
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
162
|
+
});
|
|
163
|
+
await client.connect();
|
|
164
|
+
process.stderr.write("Awaiting dispatches… (Ctrl+C to stop)\n");
|
|
165
|
+
await new Promise<never>(() => undefined);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { slug, project } = resolveProject(config, flags.get("project"));
|
|
170
|
+
const registry = registries.get(slug);
|
|
171
|
+
if (!registry) throw new Error(`No registry for project "${slug}".`);
|
|
172
|
+
const { name: environment, env } = resolveEnvironment(
|
|
173
|
+
project,
|
|
174
|
+
flags.get("env"),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const argsJson = flags.get("args");
|
|
178
|
+
const rawArgs: unknown = argsJson === undefined ? {} : JSON.parse(argsJson);
|
|
179
|
+
|
|
180
|
+
const dispatched = await registry.dispatch(command, rawArgs, {
|
|
181
|
+
project: slug,
|
|
182
|
+
environment,
|
|
183
|
+
env,
|
|
184
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
process.stdout.write(`${JSON.stringify(dispatched.result, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
void (async () => {
|
|
191
|
+
try {
|
|
192
|
+
await main();
|
|
193
|
+
} catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
}
|
|
198
|
+
})();
|