@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/connector.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { Action, ActionLogger, LoadedAction } from "./action.js";
|
|
4
|
+
import type { EnvironmentConfig, ProjectConfig } from "./config.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hazard class for a connector. Lane 1 connectors are always `read`: * they exist precisely because they are mutation-incapable, which is what
|
|
8
|
+
* lets them run with no human approval. Mutating operations are modeled as
|
|
9
|
+
* blessed *actions*, not connectors, and run behind the approval gate.
|
|
10
|
+
*/
|
|
11
|
+
export type ConnectorEffect = "read";
|
|
12
|
+
|
|
13
|
+
/** Hazard class of any capability (connector or action). */
|
|
14
|
+
export type CapabilityEffect = "read" | "mutate";
|
|
15
|
+
|
|
16
|
+
/** Whether a capability is a built-in connector or a customer-authored action. */
|
|
17
|
+
export type CapabilityKind = "connector" | "action";
|
|
18
|
+
|
|
19
|
+
/** Everything a connector needs to execute one request. */
|
|
20
|
+
export interface ConnectorContext {
|
|
21
|
+
/** The project this request targets (a slug from the runner config). */
|
|
22
|
+
readonly project: string;
|
|
23
|
+
/** The environment to run against (e.g. "dev"), within the project. */
|
|
24
|
+
readonly environment: string;
|
|
25
|
+
/** The resolved environment config (per-connector-family credentials). */
|
|
26
|
+
readonly env: EnvironmentConfig;
|
|
27
|
+
/** Structured log sink for human-facing progress. */
|
|
28
|
+
readonly log: (message: string) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A connector is a generic, read-only-constrained transport that ships in
|
|
33
|
+
* the runner, not a per-operation function. `sql.query` covers every
|
|
34
|
+
* database read; `firestore.query` covers every Firestore read. The caller
|
|
35
|
+
* supplies the query/path at request time via `params`.
|
|
36
|
+
*/
|
|
37
|
+
export interface Connector<Args = unknown, Result = unknown> {
|
|
38
|
+
/** Dotted capability name, e.g. `firestore.query`. */
|
|
39
|
+
readonly name: string;
|
|
40
|
+
/** One-line description for catalogs and the agent. */
|
|
41
|
+
readonly description: string;
|
|
42
|
+
/** Always `read`: see {@link ConnectorEffect}. */
|
|
43
|
+
readonly effect: ConnectorEffect;
|
|
44
|
+
/**
|
|
45
|
+
* Which environment-config family this connector needs (e.g. `firestore`).
|
|
46
|
+
* A built-in connector is registered for a project only when at least one
|
|
47
|
+
* of the project's environments configures that family; unset means the
|
|
48
|
+
* connector needs no per-environment credentials.
|
|
49
|
+
*/
|
|
50
|
+
readonly configKey?: keyof EnvironmentConfig;
|
|
51
|
+
/** Zod schema the caller's arguments are validated against at the boundary. */
|
|
52
|
+
readonly params: z.ZodType<Args>;
|
|
53
|
+
/** Execute the request. Must perform reads only. */
|
|
54
|
+
run(args: Args, ctx: ConnectorContext): Promise<Result>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a project's environments satisfy a connector's config family
|
|
59
|
+
* (the advertise-time filter), so a project without e.g. Firestore
|
|
60
|
+
* credentials never advertises the Firestore connectors.
|
|
61
|
+
*/
|
|
62
|
+
export function connectorAvailableFor(
|
|
63
|
+
connector: Connector,
|
|
64
|
+
project: ProjectConfig,
|
|
65
|
+
): boolean {
|
|
66
|
+
const key = connector.configKey;
|
|
67
|
+
if (key === undefined) return true;
|
|
68
|
+
return Object.values(project.environments).some(
|
|
69
|
+
(env) => env[key] !== undefined,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A normalized capability the registry stores and dispatches uniformly,
|
|
75
|
+
* whether it originated as a connector or an action. The descriptor sent to
|
|
76
|
+
* the control plane is derived from these fields.
|
|
77
|
+
*/
|
|
78
|
+
export interface RegisteredCapability {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
readonly description: string;
|
|
81
|
+
readonly effect: CapabilityEffect;
|
|
82
|
+
readonly kind: CapabilityKind;
|
|
83
|
+
readonly params: z.ZodType;
|
|
84
|
+
run(args: unknown, ctx: ConnectorContext): Promise<unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Result of dispatching a connector request. */
|
|
88
|
+
export interface DispatchResult {
|
|
89
|
+
readonly capability: string;
|
|
90
|
+
readonly environment: string;
|
|
91
|
+
readonly result: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Source of secret values an action declares; resolved at registration. */
|
|
95
|
+
export type SecretSource = Readonly<Record<string, string>>;
|
|
96
|
+
|
|
97
|
+
/** Funnel an action's leveled logger into the connector context's flat sink. */
|
|
98
|
+
function actionLogger(log: ConnectorContext["log"]): ActionLogger {
|
|
99
|
+
return {
|
|
100
|
+
info: (message) => log(message),
|
|
101
|
+
warn: (message) => log(`WARN: ${message}`),
|
|
102
|
+
error: (message) => log(`ERROR: ${message}`),
|
|
103
|
+
success: (message) => log(message),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Resolve the subset of secrets an action declared, failing loud if missing. */
|
|
108
|
+
function resolveSecrets(
|
|
109
|
+
action: Action,
|
|
110
|
+
source: SecretSource,
|
|
111
|
+
): Record<string, string> {
|
|
112
|
+
const resolved: Record<string, string> = {};
|
|
113
|
+
for (const key of Object.keys(action.secrets ?? {})) {
|
|
114
|
+
const value = source[key] ?? process.env[key];
|
|
115
|
+
if (value === undefined || value.length === 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Missing secret "${key}" required by action "${action.title}". ` +
|
|
118
|
+
`Provide it in the runner config's "secrets" or the environment.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
resolved[key] = value;
|
|
122
|
+
}
|
|
123
|
+
return resolved;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Thrown when a capability name is not registered. */
|
|
127
|
+
export class UnknownConnectorError extends Error {
|
|
128
|
+
constructor(requested: string, available: string[]) {
|
|
129
|
+
const list = available.map((n) => `"${n}"`).join(", ");
|
|
130
|
+
super(
|
|
131
|
+
`Unknown connector "${requested}". Available connectors: ${list || "(none)"}.`,
|
|
132
|
+
);
|
|
133
|
+
this.name = "UnknownConnectorError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The runner's connector registry: register connectors, then dispatch a
|
|
139
|
+
* request by capability name. Dispatch validates the caller's arguments
|
|
140
|
+
* against the connector's Zod schema *before* `run` executes, so a
|
|
141
|
+
* malformed or hallucinated request is rejected before it touches a
|
|
142
|
+
* datastore.
|
|
143
|
+
*/
|
|
144
|
+
export class ConnectorRegistry {
|
|
145
|
+
private readonly capabilities = new Map<string, RegisteredCapability>();
|
|
146
|
+
|
|
147
|
+
private add(capability: RegisteredCapability): void {
|
|
148
|
+
if (this.capabilities.has(capability.name)) {
|
|
149
|
+
throw new Error(`Capability "${capability.name}" is already registered.`);
|
|
150
|
+
}
|
|
151
|
+
this.capabilities.set(capability.name, capability);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
public register(connector: Connector): void {
|
|
155
|
+
this.add({
|
|
156
|
+
name: connector.name,
|
|
157
|
+
description: connector.description,
|
|
158
|
+
effect: connector.effect,
|
|
159
|
+
kind: "connector",
|
|
160
|
+
params: connector.params,
|
|
161
|
+
run: (args, ctx) => connector.run(args, ctx),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
public registerAll(connectors: readonly Connector[]): void {
|
|
166
|
+
for (const connector of connectors) {
|
|
167
|
+
this.register(connector);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Register a customer-authored action as a capability. The action's `input`
|
|
173
|
+
* schema becomes the capability params; its declared secrets are resolved
|
|
174
|
+
* from `secrets` (falling back to `process.env`) at dispatch.
|
|
175
|
+
*/
|
|
176
|
+
public registerAction(loaded: LoadedAction, secrets: SecretSource): void {
|
|
177
|
+
const { id, action } = loaded;
|
|
178
|
+
this.add({
|
|
179
|
+
name: id,
|
|
180
|
+
description: action.description,
|
|
181
|
+
effect: action.effect ?? "mutate",
|
|
182
|
+
kind: "action",
|
|
183
|
+
params: action.input,
|
|
184
|
+
run: (args, ctx) =>
|
|
185
|
+
Promise.resolve(
|
|
186
|
+
action.run({
|
|
187
|
+
input: args,
|
|
188
|
+
secrets: resolveSecrets(action, secrets),
|
|
189
|
+
environment: ctx.environment,
|
|
190
|
+
log: actionLogger(ctx.log),
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public list(): RegisteredCapability[] {
|
|
197
|
+
return [...this.capabilities.values()];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
public async dispatch(
|
|
201
|
+
capability: string,
|
|
202
|
+
rawArgs: unknown,
|
|
203
|
+
ctx: ConnectorContext,
|
|
204
|
+
): Promise<DispatchResult> {
|
|
205
|
+
const entry = this.capabilities.get(capability);
|
|
206
|
+
if (!entry) {
|
|
207
|
+
throw new UnknownConnectorError(capability, [
|
|
208
|
+
...this.capabilities.keys(),
|
|
209
|
+
]);
|
|
210
|
+
}
|
|
211
|
+
const args = entry.params.parse(rawArgs);
|
|
212
|
+
const result = await entry.run(args, ctx);
|
|
213
|
+
return { capability, environment: ctx.environment, result };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applicationDefault,
|
|
3
|
+
cert,
|
|
4
|
+
getApps,
|
|
5
|
+
initializeApp,
|
|
6
|
+
} from "firebase-admin/app";
|
|
7
|
+
import {
|
|
8
|
+
type Firestore,
|
|
9
|
+
getFirestore,
|
|
10
|
+
type Query,
|
|
11
|
+
} from "firebase-admin/firestore";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
import type { FirestoreEnvironment } from "../config.js";
|
|
15
|
+
import type { Connector, ConnectorContext } from "../connector.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Firestore read connectors: the Lane 1 engine for a Firestore-backed
|
|
19
|
+
* project. They are read-only twice over: Layer 1 is the service
|
|
20
|
+
* account's `roles/datastore.viewer` IAM role (GCP rejects any write at the
|
|
21
|
+
* API boundary), and Layer 2 is this code, which only ever calls read
|
|
22
|
+
* methods. Mutations are never expressed as a connector.
|
|
23
|
+
*
|
|
24
|
+
* The Firebase Admin SDK bypasses Firestore Security Rules, so the
|
|
25
|
+
* read-only guarantee comes from the IAM role on the key, not from rules.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LIMIT = 100;
|
|
29
|
+
const MAX_LIMIT = 1000;
|
|
30
|
+
|
|
31
|
+
const whereOpSchema = z.enum([
|
|
32
|
+
"==",
|
|
33
|
+
"!=",
|
|
34
|
+
"<",
|
|
35
|
+
"<=",
|
|
36
|
+
">",
|
|
37
|
+
">=",
|
|
38
|
+
"array-contains",
|
|
39
|
+
"array-contains-any",
|
|
40
|
+
"in",
|
|
41
|
+
"not-in",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const whereSchema = z.object({
|
|
45
|
+
field: z.string().min(1),
|
|
46
|
+
op: whereOpSchema,
|
|
47
|
+
value: z.unknown(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const orderBySchema = z.object({
|
|
51
|
+
field: z.string().min(1),
|
|
52
|
+
direction: z.enum(["asc", "desc"]).optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function firestoreConfig(ctx: ConnectorContext): FirestoreEnvironment {
|
|
56
|
+
const firestore = ctx.env.firestore;
|
|
57
|
+
if (!firestore) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Environment "${ctx.environment}" of project "${ctx.project}" has no firestore configuration.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return firestore;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve (and cache, via the Admin SDK app registry) a Firestore handle. */
|
|
66
|
+
function firestoreFor(ctx: ConnectorContext): Firestore {
|
|
67
|
+
const env = firestoreConfig(ctx);
|
|
68
|
+
const appName = `polycore-runner-${ctx.project}-${ctx.environment}`;
|
|
69
|
+
const existing = getApps().find((a) => a.name === appName);
|
|
70
|
+
// A key path uses that file's credential (local/dev); without one we fall
|
|
71
|
+
// back to Application Default Credentials, the GCP runtime service account
|
|
72
|
+
// (Cloud Run / GCE), which must itself carry `roles/datastore.viewer`.
|
|
73
|
+
const credential = env.serviceAccountKeyPath
|
|
74
|
+
? cert(env.serviceAccountKeyPath)
|
|
75
|
+
: applicationDefault();
|
|
76
|
+
const app =
|
|
77
|
+
existing ??
|
|
78
|
+
initializeApp({ credential, projectId: env.projectId }, appName);
|
|
79
|
+
return getFirestore(app);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type WhereClause = z.infer<typeof whereSchema>;
|
|
83
|
+
|
|
84
|
+
function applyWhere(query: Query, clauses: WhereClause[] | undefined): Query {
|
|
85
|
+
let next = query;
|
|
86
|
+
for (const clause of clauses ?? []) {
|
|
87
|
+
next = next.where(clause.field, clause.op, clause.value);
|
|
88
|
+
}
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const queryParams = z.object({
|
|
93
|
+
collection: z.string().min(1),
|
|
94
|
+
where: z.array(whereSchema).optional(),
|
|
95
|
+
orderBy: orderBySchema.optional(),
|
|
96
|
+
limit: z.number().int().positive().max(MAX_LIMIT).optional(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const firestoreQuery: Connector<z.infer<typeof queryParams>, unknown> = {
|
|
100
|
+
name: "firestore.query",
|
|
101
|
+
description:
|
|
102
|
+
"Read documents from a Firestore collection with optional where filters, ordering, and a limit. Returns at most `limit` documents (a capped page for inspection); do NOT use it to count or total; use firestore.count for that. Read-only.",
|
|
103
|
+
effect: "read",
|
|
104
|
+
configKey: "firestore",
|
|
105
|
+
params: queryParams,
|
|
106
|
+
async run(args, ctx) {
|
|
107
|
+
const db = firestoreFor(ctx);
|
|
108
|
+
let query: Query = db.collection(args.collection);
|
|
109
|
+
query = applyWhere(query, args.where);
|
|
110
|
+
if (args.orderBy) {
|
|
111
|
+
query = query.orderBy(
|
|
112
|
+
args.orderBy.field,
|
|
113
|
+
args.orderBy.direction ?? "asc",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
query = query.limit(Math.min(args.limit ?? DEFAULT_LIMIT, MAX_LIMIT));
|
|
117
|
+
|
|
118
|
+
ctx.log(`Querying ${args.collection} in ${ctx.environment}…`);
|
|
119
|
+
const snap = await query.get();
|
|
120
|
+
return snap.docs.map((doc) => ({
|
|
121
|
+
id: doc.id,
|
|
122
|
+
data: doc.data(),
|
|
123
|
+
}));
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const getParams = z.object({
|
|
128
|
+
collection: z.string().min(1),
|
|
129
|
+
id: z.string().min(1),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const firestoreGet: Connector<z.infer<typeof getParams>, unknown> = {
|
|
133
|
+
name: "firestore.get",
|
|
134
|
+
description:
|
|
135
|
+
"Read a single Firestore document by collection and id. Read-only.",
|
|
136
|
+
effect: "read",
|
|
137
|
+
configKey: "firestore",
|
|
138
|
+
params: getParams,
|
|
139
|
+
async run(args, ctx) {
|
|
140
|
+
const db = firestoreFor(ctx);
|
|
141
|
+
ctx.log(`Reading ${args.collection}/${args.id} in ${ctx.environment}…`);
|
|
142
|
+
const doc = await db.collection(args.collection).doc(args.id).get();
|
|
143
|
+
if (!doc.exists) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return { id: doc.id, data: doc.data() as Record<string, unknown> };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const countParams = z.object({
|
|
151
|
+
collection: z.string().min(1),
|
|
152
|
+
where: z.array(whereSchema).optional(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const firestoreCount: Connector<z.infer<typeof countParams>, number> = {
|
|
156
|
+
name: "firestore.count",
|
|
157
|
+
description:
|
|
158
|
+
"Count documents in a Firestore collection (optionally filtered) using a server-side aggregation. It counts EVERY matching document, so it is the correct, exact tool for any 'how many' / total question. For 'how many <record>s', count the <record> collection (e.g. users → the \"users\" collection) with NO filter unless a subset is explicitly asked for; if unsure of the exact collection name, call firestore.listCollections first. Read-only.",
|
|
159
|
+
effect: "read",
|
|
160
|
+
configKey: "firestore",
|
|
161
|
+
params: countParams,
|
|
162
|
+
async run(args, ctx) {
|
|
163
|
+
const db = firestoreFor(ctx);
|
|
164
|
+
const query = applyWhere(db.collection(args.collection), args.where);
|
|
165
|
+
ctx.log(`Counting ${args.collection} in ${ctx.environment}…`);
|
|
166
|
+
const snap = await query.count().get();
|
|
167
|
+
return snap.data().count;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const listCollections: Connector<Record<string, never>, string[]> = {
|
|
172
|
+
name: "firestore.listCollections",
|
|
173
|
+
description:
|
|
174
|
+
"List the top-level Firestore collection ids. Call this FIRST to ground yourself whenever you are not certain which collection holds the kind of record the question is about, then count/query that collection. Map the record asked about (a 'user', an 'order', a 'workspace') to its collection. The name of an organization, product, team, or environment is NEVER a collection: it only identifies whose system you are querying, so never treat it as a collection, id, or filter. Read-only.",
|
|
175
|
+
effect: "read",
|
|
176
|
+
configKey: "firestore",
|
|
177
|
+
params: z.object({}),
|
|
178
|
+
async run(_args, ctx) {
|
|
179
|
+
const db = firestoreFor(ctx);
|
|
180
|
+
ctx.log(`Listing collections in ${ctx.environment}…`);
|
|
181
|
+
const collections = await db.listCollections();
|
|
182
|
+
return collections.map((c) => c.id);
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/** All Firestore read connectors, ready to register. */
|
|
187
|
+
export const firestoreConnectors: readonly Connector[] = [
|
|
188
|
+
firestoreQuery,
|
|
189
|
+
firestoreGet,
|
|
190
|
+
firestoreCount,
|
|
191
|
+
listCollections,
|
|
192
|
+
];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { LoadedAction } from "./action.js";
|
|
2
|
+
import type { ProjectConfig, RunnerConfig } from "./config.js";
|
|
3
|
+
import {
|
|
4
|
+
connectorAvailableFor,
|
|
5
|
+
ConnectorRegistry,
|
|
6
|
+
type SecretSource,
|
|
7
|
+
} from "./connector.js";
|
|
8
|
+
import { firestoreConnectors } from "./connectors/firestore.js";
|
|
9
|
+
import { loadActions } from "./load-actions.js";
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
Action,
|
|
13
|
+
ActionEffect,
|
|
14
|
+
ActionLogger,
|
|
15
|
+
ActionRunContext,
|
|
16
|
+
LoadedAction,
|
|
17
|
+
} from "./action.js";
|
|
18
|
+
export { defineAction } from "./action.js";
|
|
19
|
+
export type {
|
|
20
|
+
ControlPlaneConfig,
|
|
21
|
+
EnvironmentConfig,
|
|
22
|
+
FirestoreEnvironment,
|
|
23
|
+
ProjectConfig,
|
|
24
|
+
RunnerConfig,
|
|
25
|
+
} from "./config.js";
|
|
26
|
+
export {
|
|
27
|
+
buildRunnerConfigFromEnv,
|
|
28
|
+
ControlPlaneConfigSchema,
|
|
29
|
+
EnvironmentConfigSchema,
|
|
30
|
+
FirestoreEnvironmentSchema,
|
|
31
|
+
hasEnvConfig,
|
|
32
|
+
loadRunnerConfig,
|
|
33
|
+
parseRunnerConfig,
|
|
34
|
+
ProjectConfigSchema,
|
|
35
|
+
resolveEnvironment,
|
|
36
|
+
resolveProject,
|
|
37
|
+
resolveProjectContext,
|
|
38
|
+
RUNNER_CONFIG_ENV,
|
|
39
|
+
RUNNER_PROJECTS_ENV,
|
|
40
|
+
RunnerConfigSchema,
|
|
41
|
+
} from "./config.js";
|
|
42
|
+
export type { RunnerClientOptions } from "./connect.js";
|
|
43
|
+
export {
|
|
44
|
+
describeConnectors,
|
|
45
|
+
describeProjects,
|
|
46
|
+
RunnerClient,
|
|
47
|
+
} from "./connect.js";
|
|
48
|
+
export type {
|
|
49
|
+
CapabilityEffect,
|
|
50
|
+
CapabilityKind,
|
|
51
|
+
Connector,
|
|
52
|
+
ConnectorContext,
|
|
53
|
+
ConnectorEffect,
|
|
54
|
+
DispatchResult,
|
|
55
|
+
RegisteredCapability,
|
|
56
|
+
SecretSource,
|
|
57
|
+
} from "./connector.js";
|
|
58
|
+
export {
|
|
59
|
+
connectorAvailableFor,
|
|
60
|
+
ConnectorRegistry,
|
|
61
|
+
UnknownConnectorError,
|
|
62
|
+
} from "./connector.js";
|
|
63
|
+
export { firestoreConnectors } from "./connectors/firestore.js";
|
|
64
|
+
export { loadActions } from "./load-actions.js";
|
|
65
|
+
// The pinned Zod, re-exported so action authors share the runner's exact
|
|
66
|
+
// instance, schemas must be introspectable by the runner's `z.toJSONSchema`.
|
|
67
|
+
// Authors import `{ defineAction, z }` from "@polycore/runner", never from a
|
|
68
|
+
// desktop package or a second `zod` copy.
|
|
69
|
+
export { z } from "zod";
|
|
70
|
+
|
|
71
|
+
/** All built-in (Lane 1) connectors; SQL/HTTP families register here as they land. */
|
|
72
|
+
const builtinConnectors = [...firestoreConnectors];
|
|
73
|
+
|
|
74
|
+
export interface CreateRegistryOptions {
|
|
75
|
+
/** Customer actions to serve alongside the built-in connectors. */
|
|
76
|
+
readonly actions?: readonly LoadedAction[];
|
|
77
|
+
/** Secret values available to those actions. */
|
|
78
|
+
readonly secrets?: SecretSource;
|
|
79
|
+
/**
|
|
80
|
+
* When given, built-in connectors are filtered to those whose config
|
|
81
|
+
* family at least one of the project's environments configures.
|
|
82
|
+
*/
|
|
83
|
+
readonly project?: ProjectConfig;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a registry with the runner's built-in connectors (Lane 1) plus any
|
|
88
|
+
* customer-authored actions (Lane 2/3).
|
|
89
|
+
*/
|
|
90
|
+
export function createRegistry(
|
|
91
|
+
options: CreateRegistryOptions = {},
|
|
92
|
+
): ConnectorRegistry {
|
|
93
|
+
const registry = new ConnectorRegistry();
|
|
94
|
+
const project = options.project;
|
|
95
|
+
const builtins = project
|
|
96
|
+
? builtinConnectors.filter((c) => connectorAvailableFor(c, project))
|
|
97
|
+
: builtinConnectors;
|
|
98
|
+
registry.registerAll(builtins);
|
|
99
|
+
for (const loaded of options.actions ?? []) {
|
|
100
|
+
registry.registerAction(loaded, options.secrets ?? {});
|
|
101
|
+
}
|
|
102
|
+
return registry;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build one registry per configured project: built-in connectors filtered
|
|
107
|
+
* by what the project's environments configure, plus the project's own
|
|
108
|
+
* actions with its secrets (project secrets win over runner-level secrets;
|
|
109
|
+
* both fall back to `process.env` at dispatch).
|
|
110
|
+
*/
|
|
111
|
+
export async function buildProjectRegistries(
|
|
112
|
+
config: RunnerConfig,
|
|
113
|
+
): Promise<Map<string, ConnectorRegistry>> {
|
|
114
|
+
const registries = new Map<string, ConnectorRegistry>();
|
|
115
|
+
for (const [slug, project] of Object.entries(config.projects)) {
|
|
116
|
+
const actions = project.actionsDir
|
|
117
|
+
? await loadActions(project.actionsDir)
|
|
118
|
+
: [];
|
|
119
|
+
registries.set(
|
|
120
|
+
slug,
|
|
121
|
+
createRegistry({
|
|
122
|
+
project,
|
|
123
|
+
actions,
|
|
124
|
+
secrets: { ...config.secrets, ...project.secrets },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return registries;
|
|
129
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { tsImport } from "tsx/esm/api";
|
|
5
|
+
|
|
6
|
+
import type { Action, LoadedAction } from "./action.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Discover and load customer-authored actions from a directory. Layout mirrors
|
|
10
|
+
* the rest of the framework: one folder per action, each holding an `action.ts`
|
|
11
|
+
* (or `.js`); the folder path relative to `dir` is the action's id (and its
|
|
12
|
+
* dispatch capability name). A subfolder without an action file is a group and
|
|
13
|
+
* is recursed into.
|
|
14
|
+
*
|
|
15
|
+
* Modules are loaded via tsx's programmatic importer so the runner can load
|
|
16
|
+
* raw `.ts` regardless of how its own process was started, exactly like the
|
|
17
|
+
* desktop runtime's loader.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadActions(dir: string): Promise<LoadedAction[]> {
|
|
20
|
+
const loaded: LoadedAction[] = [];
|
|
21
|
+
await walk(dir, dir, loaded);
|
|
22
|
+
loaded.sort((a, b) => a.id.localeCompare(b.id));
|
|
23
|
+
return loaded;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ACTION_FILES = ["action.ts", "action.js"];
|
|
27
|
+
|
|
28
|
+
async function walk(
|
|
29
|
+
root: string,
|
|
30
|
+
current: string,
|
|
31
|
+
out: LoadedAction[],
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (!entry.isDirectory()) continue;
|
|
36
|
+
const childDir = join(current, entry.name);
|
|
37
|
+
const children = await readdir(childDir, { withFileTypes: true });
|
|
38
|
+
const actionFile = children.find(
|
|
39
|
+
(child) => child.isFile() && ACTION_FILES.includes(child.name),
|
|
40
|
+
);
|
|
41
|
+
if (actionFile) {
|
|
42
|
+
const action = await importAction(join(childDir, actionFile.name));
|
|
43
|
+
out.push({ id: toPosixId(relative(root, childDir)), action });
|
|
44
|
+
} else {
|
|
45
|
+
await walk(root, childDir, out);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toPosixId(relativePath: string): string {
|
|
51
|
+
return relativePath.split(/[\\/]/).join("/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function importAction(file: string): Promise<Action> {
|
|
55
|
+
const mod: unknown = await tsImport(file, import.meta.url);
|
|
56
|
+
const exported = unwrapDefaultExport(mod);
|
|
57
|
+
assertAction(exported, file);
|
|
58
|
+
return exported;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ESM gives `default`; tsx's CJS bridge nests `{ __esModule, default }`. */
|
|
62
|
+
function unwrapDefaultExport(mod: unknown): unknown {
|
|
63
|
+
if (mod === null || typeof mod !== "object" || !("default" in mod)) {
|
|
64
|
+
return mod;
|
|
65
|
+
}
|
|
66
|
+
const direct: unknown = mod.default;
|
|
67
|
+
if (
|
|
68
|
+
direct !== null &&
|
|
69
|
+
typeof direct === "object" &&
|
|
70
|
+
"__esModule" in direct &&
|
|
71
|
+
"default" in direct
|
|
72
|
+
) {
|
|
73
|
+
return direct.default;
|
|
74
|
+
}
|
|
75
|
+
return direct;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertAction(value: unknown, file: string): asserts value is Action {
|
|
79
|
+
// The SDK's `defineAction` returns a *callable* handle (a function with the
|
|
80
|
+
// spec assigned as properties), while the runner's own `defineAction`
|
|
81
|
+
// returns a plain object. Accept both: what matters is the action shape.
|
|
82
|
+
if (
|
|
83
|
+
value === null ||
|
|
84
|
+
(typeof value !== "object" && typeof value !== "function")
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(`Action module "${file}" must default-export an action.`);
|
|
87
|
+
}
|
|
88
|
+
const candidate = value as Record<string, unknown>;
|
|
89
|
+
if (typeof candidate["run"] !== "function") {
|
|
90
|
+
throw new Error(`Action "${file}" is missing a "run" function.`);
|
|
91
|
+
}
|
|
92
|
+
if (!isSchema(candidate["input"]) || !isSchema(candidate["output"])) {
|
|
93
|
+
throw new Error(`Action "${file}" must declare Zod "input" and "output".`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isSchema(value: unknown): boolean {
|
|
98
|
+
return (
|
|
99
|
+
value !== null &&
|
|
100
|
+
typeof value === "object" &&
|
|
101
|
+
"safeParse" in value &&
|
|
102
|
+
typeof value.safeParse === "function"
|
|
103
|
+
);
|
|
104
|
+
}
|