runline 0.24.0 → 0.26.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.
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { createRequire } from "node:module";
3
3
  import { Worker } from "node:worker_threads";
4
4
  import { applyEnvOverrides, updateConnectionConfig } from "../config/loader.js";
5
- import { formatValidationError, helpInputs, isTypedInputSchema, validateTypedInput, } from "../plugin/schema.js";
5
+ import { formatValidationError, helpInputs, isTypedInputSchema, normalizeActionInput, validateTypedInput, } from "../plugin/schema.js";
6
6
  /**
7
7
  * How many bodies one worker may run before it is retired.
8
8
  *
@@ -161,6 +161,16 @@ export class ExecutionEngine {
161
161
  try {
162
162
  acquired = this.acquire(shape, () => new Worker(buildWorkerSource(pluginNames, helpData), {
163
163
  eval: true,
164
+ // Empty by construction: without an `env` option the worker
165
+ // inherits a copy of the host's entire environment, and
166
+ // Runline lives inside credential-bearing hosts (vex's
167
+ // server process, the pi agent process). Bodies hold a real
168
+ // `require` and a live `process`, so an inherited env is one
169
+ // line of agent code away from every host secret. Nothing
170
+ // in the worker needs an environment: actions execute
171
+ // host-side, connection configs are hydrated host-side, and
172
+ // ferrosearch is required by absolute path.
173
+ env: {},
164
174
  resourceLimits: { maxOldGenerationSizeMb: memoryLimitMb },
165
175
  }));
166
176
  }
@@ -295,7 +305,10 @@ export class ExecutionEngine {
295
305
  reply({ t: "run", runId, body });
296
306
  });
297
307
  }
298
- async invokeAction(path, args) {
308
+ async invokeAction(path, rawArgs) {
309
+ // A call written with no arguments reaches the action as `{}`, not
310
+ // `undefined`, so plugins can read `input.foo` without guarding.
311
+ const args = normalizeActionInput(rawArgs);
299
312
  const resolved = this.registry.resolveAction(path);
300
313
  if (!resolved) {
301
314
  throw new Error(`Unknown action: ${path}${this.selfPrefixHint(path)}`);
@@ -15,5 +15,17 @@ export declare function legacyFields(schema: InputSchema | undefined): LegacyInp
15
15
  export declare function connectionFields(schema: ConnectionSchema | undefined): LegacyConnectionSchema;
16
16
  export declare function helpInputs(schema: InputSchema | undefined): Record<string, HelpInput>;
17
17
  export declare function validateLegacyInput(schema: LegacyInputSchema, input: unknown): ValidationResult;
18
- export declare function validateTypedInput(schema: TypedInputSchema, input: unknown): ValidationResult;
18
+ /**
19
+ * A call written with no arguments is a call with an empty argument set.
20
+ *
21
+ * `linear.user.me()` is the most natural way to invoke an action that
22
+ * takes nothing, and validating `undefined` against an object schema
23
+ * rejected it every time. It also made a genuinely missing input report
24
+ * "must be object" instead of naming the field.
25
+ *
26
+ * Only `undefined` is normalized: an explicit `null` or `42` is an
27
+ * argument, and a wrong one.
28
+ */
29
+ export declare function normalizeActionInput(input: unknown): unknown;
30
+ export declare function validateTypedInput(schema: TypedInputSchema, rawInput: unknown): ValidationResult;
19
31
  export declare function formatValidationError(path: string, result: ValidationResult): string;
@@ -1,9 +1,18 @@
1
1
  import { Check, Errors } from "typebox/value";
2
+ /**
3
+ * How deep `describe` will follow a schema. Nesting past this is
4
+ * vanishingly rare, and a self-referential schema would otherwise
5
+ * recurse until the stack gave out. A field cut off here says so, so
6
+ * that "no properties" and "stopped looking" stay distinguishable.
7
+ */
8
+ const MAX_DESCRIBE_DEPTH = 5;
2
9
  export function isTypedInputSchema(schema) {
3
10
  if (!schema || typeof schema !== "object")
4
11
  return false;
5
12
  const candidate = schema;
6
- return typeof candidate.type === "string" || Array.isArray(candidate.anyOf);
13
+ // A bare union is a typed schema too. Missing that here would classify
14
+ // it as legacy and iterate its JSON Schema keywords as field names.
15
+ return typeof candidate.type === "string" || Boolean(branchesOf(candidate));
7
16
  }
8
17
  export function legacyFields(schema) {
9
18
  if (!schema || isTypedInputSchema(schema))
@@ -47,19 +56,79 @@ export function helpInputs(schema) {
47
56
  const metadata = schema;
48
57
  if (metadata.type !== "object")
49
58
  return {};
50
- const required = new Set(metadata.required ?? []);
51
- return Object.fromEntries(Object.entries(metadata.properties ?? {}).map(([key, field]) => [
59
+ return describeProperties(metadata, 0);
60
+ }
61
+ function describeProperties(schema, depth) {
62
+ const required = new Set(schema.required ?? []);
63
+ return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([key, field]) => [
52
64
  key,
53
- {
54
- type: baseType(field),
55
- displayType: displayType(field),
56
- required: required.has(key),
57
- description: field.description,
58
- enum: enumValues(field),
59
- const: field.const,
60
- },
65
+ describeField(field, required.has(key), depth),
61
66
  ]));
62
67
  }
68
+ /**
69
+ * Describe one input, following arrays into their item shape, objects
70
+ * into their properties, and unions into each branch.
71
+ *
72
+ * A parameter typed only as "array" tells an agent nothing about what to
73
+ * put in it, so the shape has to be guessed and the first call is a coin
74
+ * flip. "object | object" is the same dead end wearing a union: two
75
+ * shapes are on offer and neither is shown. Reporting the nested shape
76
+ * is the difference between describe being documentation and being a
77
+ * type name.
78
+ */
79
+ function describeField(field, required, depth) {
80
+ const described = {
81
+ type: baseType(field),
82
+ displayType: displayType(field),
83
+ required,
84
+ description: field.description,
85
+ enum: enumValues(field),
86
+ const: field.const,
87
+ };
88
+ const branches = unionBranches(field);
89
+ if (depth >= MAX_DESCRIBE_DEPTH) {
90
+ // Only claim truncation where something was actually left unread.
91
+ if (field.items || field.properties || branches)
92
+ described.truncated = true;
93
+ return described;
94
+ }
95
+ if (field.items) {
96
+ // An array element is not "required"; the flag has no meaning here.
97
+ described.items = describeField(field.items, false, depth + 1);
98
+ }
99
+ if (field.properties) {
100
+ described.properties = describeProperties(field, depth + 1);
101
+ }
102
+ if (branches) {
103
+ described.variants = branches.map((branch) => describeField(branch, false, depth + 1));
104
+ }
105
+ return described;
106
+ }
107
+ /**
108
+ * The branches of a union worth describing individually.
109
+ *
110
+ * Two unions need no expansion. A union of literals is already fully
111
+ * described by `enum`, and a union of bare scalars is already fully
112
+ * described by `displayType` — `string | null` is Linear's clearable
113
+ * pattern and repeating it as two empty branches only buries the unions
114
+ * that do carry shape.
115
+ */
116
+ function unionBranches(field) {
117
+ const branches = branchesOf(field);
118
+ if (!branches)
119
+ return undefined;
120
+ if (enumValues(field))
121
+ return undefined;
122
+ return branches.some(hasShape) ? branches : undefined;
123
+ }
124
+ /** Whether a schema says more than its type name does. */
125
+ function hasShape(schema) {
126
+ return Boolean(schema.properties ||
127
+ schema.items ||
128
+ branchesOf(schema) ||
129
+ schema.enum?.length ||
130
+ schema.description);
131
+ }
63
132
  export function validateLegacyInput(schema, input) {
64
133
  const provided = input && typeof input === "object"
65
134
  ? input
@@ -86,7 +155,22 @@ export function validateLegacyInput(schema, input) {
86
155
  }
87
156
  return validationResult({ missing, unknown, typeErrors });
88
157
  }
89
- export function validateTypedInput(schema, input) {
158
+ /**
159
+ * A call written with no arguments is a call with an empty argument set.
160
+ *
161
+ * `linear.user.me()` is the most natural way to invoke an action that
162
+ * takes nothing, and validating `undefined` against an object schema
163
+ * rejected it every time. It also made a genuinely missing input report
164
+ * "must be object" instead of naming the field.
165
+ *
166
+ * Only `undefined` is normalized: an explicit `null` or `42` is an
167
+ * argument, and a wrong one.
168
+ */
169
+ export function normalizeActionInput(input) {
170
+ return input === undefined ? {} : input;
171
+ }
172
+ export function validateTypedInput(schema, rawInput) {
173
+ const input = normalizeActionInput(rawInput);
90
174
  if (Check(schema, input))
91
175
  return validationResult({});
92
176
  return validationResult({
@@ -130,9 +214,20 @@ function validationResult(input) {
130
214
  errors,
131
215
  };
132
216
  }
217
+ /**
218
+ * The alternatives of a union, however the schema spells it. TypeBox
219
+ * emits `anyOf`; hand-written schemas in the plugins use `oneOf`. Every
220
+ * reader here has to treat them the same, so they are unified once, and
221
+ * an empty branch list is reported as no union at all.
222
+ */
223
+ function branchesOf(schema) {
224
+ const branches = schema.anyOf ?? schema.oneOf;
225
+ return branches?.length ? branches : undefined;
226
+ }
133
227
  function displayType(schema) {
134
- if (schema.anyOf?.length)
135
- return schema.anyOf.map(displayType).join(" | ");
228
+ const branches = branchesOf(schema);
229
+ if (branches)
230
+ return branches.map(displayType).join(" | ");
136
231
  if (schema.enum?.length)
137
232
  return schema.enum.map(String).join(" | ");
138
233
  if (schema.const !== undefined)
@@ -140,8 +235,9 @@ function displayType(schema) {
140
235
  return schema.type ?? "unknown";
141
236
  }
142
237
  function baseType(schema) {
143
- if (schema.anyOf?.length) {
144
- const types = [...new Set(schema.anyOf.map(baseType))];
238
+ const branches = branchesOf(schema);
239
+ if (branches) {
240
+ const types = [...new Set(branches.map(baseType))];
145
241
  return types.length === 1 ? types[0] : types.join(" | ");
146
242
  }
147
243
  if (schema.type)
@@ -153,9 +249,9 @@ function baseType(schema) {
153
249
  function enumValues(schema) {
154
250
  if (schema.enum?.length)
155
251
  return schema.enum;
156
- if (schema.anyOf?.length &&
157
- schema.anyOf.every((s) => s.const !== undefined)) {
158
- return schema.anyOf.map((s) => s.const);
252
+ const branches = branchesOf(schema);
253
+ if (branches?.every((s) => s.const !== undefined)) {
254
+ return branches.map((s) => s.const);
159
255
  }
160
256
  return undefined;
161
257
  }
@@ -24,6 +24,17 @@ export interface HelpInput {
24
24
  description?: string;
25
25
  enum?: unknown[];
26
26
  const?: unknown;
27
+ /** Element shape, for array inputs. */
28
+ items?: HelpInput;
29
+ /** Nested field shapes, for object inputs. */
30
+ properties?: Record<string, HelpInput>;
31
+ /**
32
+ * Branches of a union, where at least one carries shape the display
33
+ * type cannot express. Absent for unions of literals or bare scalars.
34
+ */
35
+ variants?: HelpInput[];
36
+ /** Set when nesting ran past the describe depth limit and was not read. */
37
+ truncated?: boolean;
27
38
  }
28
39
  export type ActionAccess = "read" | "write";
29
40
  export interface ActionDef {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Code mode for agents \u2014 turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",