@boboddy/sdk 0.2.10-alpha → 0.2.13-alpha
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/dist/boboddy-config-parser.d.ts +0 -5
- package/dist/boboddy-config-parser.js +0 -6
- package/dist/definitions/steps/define-step.d.ts +22 -1
- package/dist/definitions/steps/index.js +2 -1
- package/dist/definitions/steps/step-definitions-client.d.ts +30 -0
- package/dist/definitions/validation/index.d.ts +2 -0
- package/dist/definitions/validation/index.js +440 -0
- package/dist/definitions/validation/json-schema-paths.d.ts +45 -0
- package/dist/definitions/validation/validate-definition-specs.d.ts +25 -0
- package/dist/generated/types.gen.d.ts +110 -5
- package/dist/health-checks.d.ts +45 -0
- package/dist/health-checks.js +14308 -0
- package/dist/index.js +2 -7
- package/dist/push/collect-definitions.d.ts +21 -0
- package/dist/push/index.d.ts +2 -0
- package/dist/push/index.js +471 -68
- package/dist/push/push-from-directory.d.ts +7 -1
- package/dist/step-execution-plane-client.d.ts +10 -5
- package/package.json +9 -1
|
@@ -15,11 +15,6 @@ export type BoboddyServiceDefinition = {
|
|
|
15
15
|
targetPort: number;
|
|
16
16
|
protocol: string;
|
|
17
17
|
};
|
|
18
|
-
healthcheck: {
|
|
19
|
-
protocol: string;
|
|
20
|
-
path: string | null;
|
|
21
|
-
expectedStatus: number | null;
|
|
22
|
-
};
|
|
23
18
|
};
|
|
24
19
|
export type BoboddyConfig = {
|
|
25
20
|
commands: BoboddyCommandDefinition[];
|
|
@@ -147,7 +147,6 @@ var parseServices = (raw) => {
|
|
|
147
147
|
return [];
|
|
148
148
|
return Object.entries(raw).map(([name, entry]) => {
|
|
149
149
|
const expose = entry.expose ?? {};
|
|
150
|
-
const healthcheck = entry.healthcheck ?? {};
|
|
151
150
|
const dependsOn = Array.isArray(entry.dependsOn) ? entry.dependsOn.filter((d) => typeof d === "string") : [];
|
|
152
151
|
return {
|
|
153
152
|
name,
|
|
@@ -158,11 +157,6 @@ var parseServices = (raw) => {
|
|
|
158
157
|
expose: {
|
|
159
158
|
targetPort: asNumber(expose.targetPort) ?? 0,
|
|
160
159
|
protocol: asString(expose.protocol) ?? "http"
|
|
161
|
-
},
|
|
162
|
-
healthcheck: {
|
|
163
|
-
protocol: asString(healthcheck.protocol) ?? "http",
|
|
164
|
-
path: asString(healthcheck.path) ?? null,
|
|
165
|
-
expectedStatus: asNumber(healthcheck.expectedStatus) ?? null
|
|
166
160
|
}
|
|
167
161
|
};
|
|
168
162
|
});
|
|
@@ -29,6 +29,25 @@ type OpenCodeMcpServers = Record<string, {
|
|
|
29
29
|
type OpenCodePluginEntry = string | [string, Record<string, unknown>];
|
|
30
30
|
/** Full value of the OpenCode `plugin` config field. */
|
|
31
31
|
type OpenCodePlugins = OpenCodePluginEntry[];
|
|
32
|
+
type HealthCheckSeverity = "required" | "warn";
|
|
33
|
+
/**
|
|
34
|
+
* A single step-declared health check: a real tool call made against the
|
|
35
|
+
* launched environment before the agent starts working.
|
|
36
|
+
*
|
|
37
|
+
* `tool` is a bare name when `mcp` is set (resolved at runtime to
|
|
38
|
+
* `${mcp}_${tool}`); otherwise it is a flat tool id. `mcp`, when present,
|
|
39
|
+
* must name a server declared in this step's `mcpServers`.
|
|
40
|
+
*/
|
|
41
|
+
type HealthCheck = {
|
|
42
|
+
tool: string;
|
|
43
|
+
mcp?: string;
|
|
44
|
+
name?: string;
|
|
45
|
+
args?: Record<string, unknown>;
|
|
46
|
+
severity?: HealthCheckSeverity;
|
|
47
|
+
timeoutMs?: number;
|
|
48
|
+
};
|
|
49
|
+
/** Full value of a step's `healthChecks` field. */
|
|
50
|
+
type HealthChecks = HealthCheck[];
|
|
32
51
|
type SignalTypeStr = "string" | "number" | "boolean" | "object" | "array";
|
|
33
52
|
export type DotPaths<T, D extends readonly unknown[] = []> = D["length"] extends 4 ? string : unknown extends T ? string : T extends readonly unknown[] ? string : T extends object ? {
|
|
34
53
|
[K in keyof T & string]: K | (NonNullable<T[K]> extends object ? `${K}.${DotPaths<NonNullable<T[K]>, [...D, unknown]> & string}` : never);
|
|
@@ -63,6 +82,7 @@ export type DefineStepInput<TInput extends ZodType = ZodType, TResult extends Zo
|
|
|
63
82
|
features?: AnyStepFeature[];
|
|
64
83
|
mcpServers?: OpenCodeMcpServers | null;
|
|
65
84
|
plugins?: OpenCodePlugins | null;
|
|
85
|
+
healthChecks?: HealthChecks | null;
|
|
66
86
|
status?: "draft" | "active";
|
|
67
87
|
executionMode?: "workspace" | "no_workspace";
|
|
68
88
|
};
|
|
@@ -94,6 +114,7 @@ export type StepDefinitionSpec = {
|
|
|
94
114
|
}>;
|
|
95
115
|
opencodeMcpJson: OpenCodeMcpServers | null;
|
|
96
116
|
opencodePluginJson: OpenCodePlugins | null;
|
|
117
|
+
healthChecksJson: HealthChecks | null;
|
|
97
118
|
};
|
|
98
119
|
export type SignalTypeStrToTs<T extends SignalTypeStr> = T extends "string" ? string : T extends "number" ? number : T extends "boolean" ? boolean : T extends "array" ? unknown[] : T extends "object" ? object : unknown;
|
|
99
120
|
export type SignalTypeMapOf<TSignals extends readonly unknown[], TResult> = {
|
|
@@ -121,7 +142,7 @@ export declare function defineStep<TInput extends ZodType = ZodType, TResult ext
|
|
|
121
142
|
sourcePath: string;
|
|
122
143
|
key?: string;
|
|
123
144
|
}> = never[], const TFeatures extends ReadonlyArray<AnyStepFeature> = never[]>(config: Omit<DefineStepInput<TInput, TResult>, "signals" | "features"> & {
|
|
124
|
-
signals?: TSignals;
|
|
145
|
+
signals?: TSignals & readonly SignalSpecInput<TResult["_output"]>[];
|
|
125
146
|
features?: TFeatures;
|
|
126
147
|
}): TypedStepDefinitionSpec<TInput["_output"], TResult["_output"] & FeatureResultExtensions<TFeatures>, SignalKeysOf<TSignals> | FeatureSignalKeys<TFeatures>, SignalTypeMapOf<TSignals, TResult["_output"]>>;
|
|
127
148
|
export {};
|
|
@@ -12075,7 +12075,8 @@ ${feature._promptAddition}` : feature._promptAddition;
|
|
|
12075
12075
|
}))
|
|
12076
12076
|
],
|
|
12077
12077
|
opencodeMcpJson: config2.mcpServers ?? null,
|
|
12078
|
-
opencodePluginJson: config2.plugins ?? null
|
|
12078
|
+
opencodePluginJson: config2.plugins ?? null,
|
|
12079
|
+
healthChecksJson: config2.healthChecks ?? null
|
|
12079
12080
|
};
|
|
12080
12081
|
return spec;
|
|
12081
12082
|
}
|
|
@@ -48,6 +48,16 @@ declare const buildStepDefinitionsClient: (stepDefinitions: StepDefinitions) =>
|
|
|
48
48
|
};
|
|
49
49
|
} | unknown;
|
|
50
50
|
opencodePluginJson: Array<string | Array<unknown>> | unknown;
|
|
51
|
+
healthChecksJson: Array<{
|
|
52
|
+
tool: string;
|
|
53
|
+
mcp?: string;
|
|
54
|
+
name?: string;
|
|
55
|
+
args?: {
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
};
|
|
58
|
+
severity: "required" | "warn";
|
|
59
|
+
timeoutMs: number;
|
|
60
|
+
}> | unknown;
|
|
51
61
|
status: "draft" | "active" | "archived";
|
|
52
62
|
signalExtractorDefinitions: Array<{
|
|
53
63
|
id: string;
|
|
@@ -102,6 +112,16 @@ declare const buildStepDefinitionsClient: (stepDefinitions: StepDefinitions) =>
|
|
|
102
112
|
};
|
|
103
113
|
} | unknown;
|
|
104
114
|
opencodePluginJson: Array<string | Array<unknown>> | unknown;
|
|
115
|
+
healthChecksJson: Array<{
|
|
116
|
+
tool: string;
|
|
117
|
+
mcp?: string;
|
|
118
|
+
name?: string;
|
|
119
|
+
args?: {
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
};
|
|
122
|
+
severity: "required" | "warn";
|
|
123
|
+
timeoutMs: number;
|
|
124
|
+
}> | unknown;
|
|
105
125
|
status: "draft" | "active" | "archived";
|
|
106
126
|
signalExtractorDefinitions: Array<{
|
|
107
127
|
id: string;
|
|
@@ -161,6 +181,16 @@ declare const buildStepDefinitionsClient: (stepDefinitions: StepDefinitions) =>
|
|
|
161
181
|
};
|
|
162
182
|
} | unknown;
|
|
163
183
|
opencodePluginJson: Array<string | Array<unknown>> | unknown;
|
|
184
|
+
healthChecksJson: Array<{
|
|
185
|
+
tool: string;
|
|
186
|
+
mcp?: string;
|
|
187
|
+
name?: string;
|
|
188
|
+
args?: {
|
|
189
|
+
[key: string]: unknown;
|
|
190
|
+
};
|
|
191
|
+
severity: "required" | "warn";
|
|
192
|
+
timeoutMs: number;
|
|
193
|
+
}> | unknown;
|
|
164
194
|
status: "draft" | "active" | "archived";
|
|
165
195
|
signalExtractorDefinitions: Array<{
|
|
166
196
|
id: string;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/definitions/validation/json-schema-paths.ts
|
|
18
|
+
var SEGMENT_PATTERN = /([^.[\]]+)|(\[(\d+)\])/g;
|
|
19
|
+
var NUMERIC_SEGMENT = /^\d+$/;
|
|
20
|
+
var SCALAR_TYPES = new Set([
|
|
21
|
+
"string",
|
|
22
|
+
"number",
|
|
23
|
+
"integer",
|
|
24
|
+
"boolean",
|
|
25
|
+
"null"
|
|
26
|
+
]);
|
|
27
|
+
var MAX_REF_HOPS = 16;
|
|
28
|
+
var MAX_CANDIDATES = 64;
|
|
29
|
+
function parseSourcePath(sourcePath) {
|
|
30
|
+
const trimmed = sourcePath.trim();
|
|
31
|
+
const normalized = trimmed === "$" ? "" : trimmed.startsWith("$.") ? trimmed.slice(2) : trimmed;
|
|
32
|
+
if (!normalized)
|
|
33
|
+
return [];
|
|
34
|
+
return [...normalized.matchAll(SEGMENT_PATTERN)].map((match) => match[1] ?? match[3]).filter((segment) => Boolean(segment));
|
|
35
|
+
}
|
|
36
|
+
function isSchemaNode(value) {
|
|
37
|
+
return typeof value === "boolean" || typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function asRecord(node) {
|
|
40
|
+
return typeof node === "boolean" ? null : node;
|
|
41
|
+
}
|
|
42
|
+
function resolveRef(root, ref) {
|
|
43
|
+
if (!ref.startsWith("#"))
|
|
44
|
+
return null;
|
|
45
|
+
const pointer = ref.slice(1);
|
|
46
|
+
if (pointer === "" || pointer === "/")
|
|
47
|
+
return root;
|
|
48
|
+
if (!pointer.startsWith("/"))
|
|
49
|
+
return null;
|
|
50
|
+
let current = root;
|
|
51
|
+
for (const rawToken of pointer.slice(1).split("/")) {
|
|
52
|
+
const token = rawToken.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
53
|
+
if (typeof current !== "object" || current === null)
|
|
54
|
+
return null;
|
|
55
|
+
current = current[token];
|
|
56
|
+
}
|
|
57
|
+
return isSchemaNode(current) ? current : null;
|
|
58
|
+
}
|
|
59
|
+
function flatten(node, root) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const queue = [
|
|
62
|
+
{ node, hops: 0 }
|
|
63
|
+
];
|
|
64
|
+
while (queue.length > 0) {
|
|
65
|
+
const entry = queue.shift();
|
|
66
|
+
if (!entry)
|
|
67
|
+
break;
|
|
68
|
+
if (out.length >= MAX_CANDIDATES)
|
|
69
|
+
return null;
|
|
70
|
+
if (entry.hops > MAX_REF_HOPS)
|
|
71
|
+
return null;
|
|
72
|
+
const record = asRecord(entry.node);
|
|
73
|
+
if (!record) {
|
|
74
|
+
out.push(entry.node);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const ref = record["$ref"];
|
|
78
|
+
if (typeof ref === "string") {
|
|
79
|
+
const target = resolveRef(root, ref);
|
|
80
|
+
if (!target)
|
|
81
|
+
return null;
|
|
82
|
+
queue.push({ node: target, hops: entry.hops + 1 });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const branches = ["anyOf", "oneOf", "allOf"].flatMap((keyword) => {
|
|
86
|
+
const value = record[keyword];
|
|
87
|
+
return Array.isArray(value) ? value.filter(isSchemaNode) : [];
|
|
88
|
+
});
|
|
89
|
+
if (branches.length > 0) {
|
|
90
|
+
for (const branch of branches) {
|
|
91
|
+
queue.push({ node: branch, hops: entry.hops + 1 });
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
out.push(entry.node);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function typeNames(record) {
|
|
100
|
+
const raw = record["type"];
|
|
101
|
+
if (typeof raw === "string")
|
|
102
|
+
return new Set([raw]);
|
|
103
|
+
if (Array.isArray(raw)) {
|
|
104
|
+
return new Set(raw.filter((entry) => typeof entry === "string"));
|
|
105
|
+
}
|
|
106
|
+
return new Set;
|
|
107
|
+
}
|
|
108
|
+
var INDETERMINATE = { kind: "indeterminate" };
|
|
109
|
+
function stepIntoObject(record, segment) {
|
|
110
|
+
const properties = asRecord(isSchemaNode(record["properties"]) ? record["properties"] : {});
|
|
111
|
+
const declared = properties ?? {};
|
|
112
|
+
const child = declared[segment];
|
|
113
|
+
if (isSchemaNode(child))
|
|
114
|
+
return { kind: "child", node: child };
|
|
115
|
+
if (record["patternProperties"] !== undefined)
|
|
116
|
+
return INDETERMINATE;
|
|
117
|
+
if (record["additionalProperties"] !== false)
|
|
118
|
+
return INDETERMINATE;
|
|
119
|
+
return {
|
|
120
|
+
kind: "invalid",
|
|
121
|
+
reason: "unknown-property",
|
|
122
|
+
availablePaths: Object.keys(declared).sort()
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function stepIntoArray(record, segment) {
|
|
126
|
+
if (!NUMERIC_SEGMENT.test(segment)) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "invalid",
|
|
129
|
+
reason: "not-an-array-index",
|
|
130
|
+
availablePaths: []
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const prefixItems = record["prefixItems"];
|
|
134
|
+
if (Array.isArray(prefixItems)) {
|
|
135
|
+
const positional = prefixItems[Number(segment)];
|
|
136
|
+
if (isSchemaNode(positional))
|
|
137
|
+
return { kind: "child", node: positional };
|
|
138
|
+
}
|
|
139
|
+
const items = record["items"];
|
|
140
|
+
if (isSchemaNode(items))
|
|
141
|
+
return { kind: "child", node: items };
|
|
142
|
+
return INDETERMINATE;
|
|
143
|
+
}
|
|
144
|
+
function stepInto(node, segment) {
|
|
145
|
+
const record = asRecord(node);
|
|
146
|
+
if (!record || Object.keys(record).length === 0)
|
|
147
|
+
return INDETERMINATE;
|
|
148
|
+
const types = typeNames(record);
|
|
149
|
+
const objectLike = types.has("object") || record["properties"] !== undefined || record["patternProperties"] !== undefined || record["additionalProperties"] !== undefined;
|
|
150
|
+
const arrayLike = types.has("array") || record["items"] !== undefined || record["prefixItems"] !== undefined;
|
|
151
|
+
const outcomes = [];
|
|
152
|
+
if (objectLike)
|
|
153
|
+
outcomes.push(stepIntoObject(record, segment));
|
|
154
|
+
if (arrayLike)
|
|
155
|
+
outcomes.push(stepIntoArray(record, segment));
|
|
156
|
+
if (outcomes.length === 0) {
|
|
157
|
+
if (types.size > 0 && [...types].every((name) => SCALAR_TYPES.has(name))) {
|
|
158
|
+
return {
|
|
159
|
+
kind: "invalid",
|
|
160
|
+
reason: "scalar-has-no-members",
|
|
161
|
+
availablePaths: []
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return INDETERMINATE;
|
|
165
|
+
}
|
|
166
|
+
return combine(outcomes);
|
|
167
|
+
}
|
|
168
|
+
function combine(outcomes) {
|
|
169
|
+
const children = outcomes.filter((outcome) => outcome.kind === "child");
|
|
170
|
+
if (children.length > 0)
|
|
171
|
+
return children[0] ?? INDETERMINATE;
|
|
172
|
+
if (outcomes.some((outcome) => outcome.kind === "indeterminate")) {
|
|
173
|
+
return INDETERMINATE;
|
|
174
|
+
}
|
|
175
|
+
const invalid = outcomes.filter((outcome) => outcome.kind === "invalid");
|
|
176
|
+
const first = invalid[0];
|
|
177
|
+
if (!first)
|
|
178
|
+
return INDETERMINATE;
|
|
179
|
+
return {
|
|
180
|
+
kind: "invalid",
|
|
181
|
+
reason: first.reason,
|
|
182
|
+
availablePaths: [
|
|
183
|
+
...new Set(invalid.flatMap((outcome) => outcome.availablePaths))
|
|
184
|
+
].sort()
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function enumeratePaths(node, root, maxDepth = 3, limit = 40) {
|
|
188
|
+
const out = [];
|
|
189
|
+
const visit = (current, prefix, depth) => {
|
|
190
|
+
if (out.length >= limit || depth > maxDepth)
|
|
191
|
+
return;
|
|
192
|
+
for (const branch of flatten(current, root) ?? []) {
|
|
193
|
+
const record = asRecord(branch);
|
|
194
|
+
const properties = record ? asRecord(isSchemaNode(record["properties"]) ? record["properties"] : {}) : null;
|
|
195
|
+
if (!properties)
|
|
196
|
+
continue;
|
|
197
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
198
|
+
if (out.length >= limit)
|
|
199
|
+
return;
|
|
200
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
201
|
+
out.push(path);
|
|
202
|
+
if (isSchemaNode(child))
|
|
203
|
+
visit(child, path, depth + 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
visit(node, "", 1);
|
|
208
|
+
return [...new Set(out)].sort();
|
|
209
|
+
}
|
|
210
|
+
function resolveSourcePath(schema, sourcePath) {
|
|
211
|
+
const segments = parseSourcePath(sourcePath);
|
|
212
|
+
if (segments.length === 0)
|
|
213
|
+
return { kind: "resolved" };
|
|
214
|
+
let candidates = [schema];
|
|
215
|
+
let resolvedPrefix = "";
|
|
216
|
+
for (const segment of segments) {
|
|
217
|
+
const expanded = candidates.flatMap((node) => flatten(node, schema) ?? []);
|
|
218
|
+
if (expanded.length === 0)
|
|
219
|
+
return { kind: "indeterminate" };
|
|
220
|
+
const outcome = combine(expanded.map((node) => stepInto(node, segment)));
|
|
221
|
+
if (outcome.kind === "indeterminate")
|
|
222
|
+
return { kind: "indeterminate" };
|
|
223
|
+
if (outcome.kind === "invalid") {
|
|
224
|
+
const availablePaths = [
|
|
225
|
+
...new Set(expanded.flatMap((node) => enumeratePaths(node, schema)))
|
|
226
|
+
].sort();
|
|
227
|
+
return {
|
|
228
|
+
kind: "invalid",
|
|
229
|
+
resolvedPrefix,
|
|
230
|
+
segment,
|
|
231
|
+
reason: outcome.reason,
|
|
232
|
+
availablePaths
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
candidates = [outcome.node];
|
|
236
|
+
resolvedPrefix = resolvedPrefix ? `${resolvedPrefix}.${segment}` : segment;
|
|
237
|
+
}
|
|
238
|
+
return { kind: "resolved" };
|
|
239
|
+
}
|
|
240
|
+
// src/definitions/validation/validate-definition-specs.ts
|
|
241
|
+
function listPaths(paths, limit = 24) {
|
|
242
|
+
if (paths.length === 0)
|
|
243
|
+
return "";
|
|
244
|
+
if (paths.length <= limit)
|
|
245
|
+
return paths.join(", ");
|
|
246
|
+
return `${paths.slice(0, limit).join(", ")}, \u2026 (${String(paths.length - limit)} more)`;
|
|
247
|
+
}
|
|
248
|
+
function quotedOrRoot(prefix) {
|
|
249
|
+
return prefix ? `"${prefix}"` : "the result root";
|
|
250
|
+
}
|
|
251
|
+
function checkSignalSourcePaths(steps) {
|
|
252
|
+
const issues = [];
|
|
253
|
+
for (const step of steps) {
|
|
254
|
+
const schema = step.resultSchemaJson ?? null;
|
|
255
|
+
if (!schema)
|
|
256
|
+
continue;
|
|
257
|
+
for (const signal of step.signalExtractorDefinitions) {
|
|
258
|
+
const resolution = resolveSourcePath(schema, signal.sourcePath);
|
|
259
|
+
if (resolution.kind !== "invalid")
|
|
260
|
+
continue;
|
|
261
|
+
const { resolvedPrefix, segment, reason, availablePaths } = resolution;
|
|
262
|
+
const cause = reason === "not-an-array-index" ? `${quotedOrRoot(resolvedPrefix)} is an array, so "${segment}" can never index it \u2014 array segments must be numeric (e.g. "${resolvedPrefix}[0]")` : reason === "scalar-has-no-members" ? `${quotedOrRoot(resolvedPrefix)} is a scalar, so it has no property "${segment}"` : `${quotedOrRoot(resolvedPrefix)} has no property "${segment}"`;
|
|
263
|
+
const suffix = availablePaths.length > 0 ? ` Valid sourcePaths ${resolvedPrefix ? `under "${resolvedPrefix}"` : "for this step"}: ${listPaths(availablePaths)}.` : "";
|
|
264
|
+
issues.push({
|
|
265
|
+
check: "signal-source-path",
|
|
266
|
+
message: `Step "${step.key}" declares signal "${signal.key}" with sourcePath ` + `"${signal.sourcePath}", which can never resolve against the step's ` + `result schema: ${cause}.${suffix}`
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return issues;
|
|
271
|
+
}
|
|
272
|
+
function checkHealthChecks(steps) {
|
|
273
|
+
const issues = [];
|
|
274
|
+
for (const step of steps) {
|
|
275
|
+
const checks = step.healthChecksJson ?? [];
|
|
276
|
+
if (checks.length === 0)
|
|
277
|
+
continue;
|
|
278
|
+
const mcpServerKeys = Object.keys(step.opencodeMcpJson ?? {});
|
|
279
|
+
const mcpServerKeySet = new Set(mcpServerKeys);
|
|
280
|
+
checks.forEach((check, index) => {
|
|
281
|
+
const label = check.name ?? check.tool;
|
|
282
|
+
const where = `Step "${step.key}" health check #${String(index + 1)} ("${label}")`;
|
|
283
|
+
if (!check.mcp)
|
|
284
|
+
return;
|
|
285
|
+
if (!mcpServerKeySet.has(check.mcp)) {
|
|
286
|
+
issues.push({
|
|
287
|
+
check: "health-check-mcp-server",
|
|
288
|
+
message: `${where} names MCP server "${check.mcp}", but the step declares no ` + `such server in mcpServers. Declared servers: ${mcpServerKeys.length > 0 ? listPaths([...mcpServerKeys].sort()) : "(none)"}.`
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
const prefix = `${check.mcp}_`;
|
|
292
|
+
if (check.tool.startsWith(prefix)) {
|
|
293
|
+
issues.push({
|
|
294
|
+
check: "health-check-double-qualified",
|
|
295
|
+
message: `${where} sets mcp "${check.mcp}" and tool "${check.tool}", which already ` + `starts with "${prefix}". When "mcp" is set, "tool" should be the bare tool ` + `name \u2014 OpenCode resolves it to "${prefix}${check.tool}". Did you mean ` + `tool: "${check.tool.slice(prefix.length)}"?`
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return issues;
|
|
301
|
+
}
|
|
302
|
+
function routeTargets(policy) {
|
|
303
|
+
const keys = [];
|
|
304
|
+
if (policy.defaultEventType === "route" && typeof policy.defaultEventParamsJson?.["pipelineKey"] === "string") {
|
|
305
|
+
keys.push(policy.defaultEventParamsJson["pipelineKey"]);
|
|
306
|
+
}
|
|
307
|
+
for (const rule of policy.rulesJson.rules) {
|
|
308
|
+
if (rule.event.type === "route" && typeof rule.event.params?.["pipelineKey"] === "string") {
|
|
309
|
+
keys.push(rule.event.params["pipelineKey"]);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return keys;
|
|
313
|
+
}
|
|
314
|
+
function checkRouteTargets(pipelines, knownPipelineKeys) {
|
|
315
|
+
const issues = [];
|
|
316
|
+
const known = new Set([
|
|
317
|
+
...pipelines.map((pipeline) => pipeline.key),
|
|
318
|
+
...knownPipelineKeys
|
|
319
|
+
]);
|
|
320
|
+
for (const pipeline of pipelines) {
|
|
321
|
+
for (const step of pipeline.steps) {
|
|
322
|
+
for (const target of routeTargets(step.advancementPolicyDefinition)) {
|
|
323
|
+
if (known.has(target))
|
|
324
|
+
continue;
|
|
325
|
+
issues.push({
|
|
326
|
+
check: "route-target",
|
|
327
|
+
message: `Pipeline "${pipeline.key}" step "${step.stepKey}" routes to pipeline ` + `"${target}", but no pipeline with that key was found on the server or ` + `in the current push batch. Push the target pipeline first.`
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return issues;
|
|
333
|
+
}
|
|
334
|
+
function executionRanks(steps) {
|
|
335
|
+
const positions = steps.map((step) => step.position);
|
|
336
|
+
const usable = positions.every((value) => Number.isInteger(value) && value > 0) && new Set(positions).size === positions.length;
|
|
337
|
+
const indexes = steps.map((_, index) => index);
|
|
338
|
+
const ordered = usable ? [...indexes].sort((left, right) => (positions[left] ?? 0) - (positions[right] ?? 0)) : indexes;
|
|
339
|
+
return new Map(ordered.map((index, rank) => [index, rank]));
|
|
340
|
+
}
|
|
341
|
+
function bindingSource(binding) {
|
|
342
|
+
if (binding.source === "step_signal") {
|
|
343
|
+
return { stepKey: binding.stepKey, signalKey: binding.signalKey };
|
|
344
|
+
}
|
|
345
|
+
if (binding.source === "step_output") {
|
|
346
|
+
return { stepKey: binding.stepKey, signalKey: null };
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
function declaredSignalKeys(stepKey, stepsByKey, pipelineSteps) {
|
|
351
|
+
const specs = stepsByKey.get(stepKey);
|
|
352
|
+
if (!specs || specs.length === 0)
|
|
353
|
+
return null;
|
|
354
|
+
const keys = new Set;
|
|
355
|
+
for (const spec of specs) {
|
|
356
|
+
for (const signal of spec.signalExtractorDefinitions)
|
|
357
|
+
keys.add(signal.key);
|
|
358
|
+
}
|
|
359
|
+
for (const step of pipelineSteps) {
|
|
360
|
+
if (step.stepKey !== stepKey)
|
|
361
|
+
continue;
|
|
362
|
+
for (const computed of step.computedSignalDefinitions)
|
|
363
|
+
keys.add(computed.key);
|
|
364
|
+
}
|
|
365
|
+
return [...keys];
|
|
366
|
+
}
|
|
367
|
+
function checkSignalBindings(pipelines, stepsByKey) {
|
|
368
|
+
const issues = [];
|
|
369
|
+
for (const pipeline of pipelines) {
|
|
370
|
+
const ranks = executionRanks(pipeline.steps);
|
|
371
|
+
const order = [...pipeline.steps.keys()].sort((left, right) => (ranks.get(left) ?? 0) - (ranks.get(right) ?? 0)).map((index) => pipeline.steps[index]?.stepKey ?? "");
|
|
372
|
+
const orderHint = `Steps in "${pipeline.key}", in order: ${order.join(" \u2192 ")}.`;
|
|
373
|
+
pipeline.steps.forEach((step, index) => {
|
|
374
|
+
const consumerRank = ranks.get(index) ?? index;
|
|
375
|
+
const where = `Pipeline "${pipeline.key}" step "${step.stepKey}"`;
|
|
376
|
+
for (const [field, binding] of Object.entries(step.inputBindingsJson)) {
|
|
377
|
+
const source = bindingSource(binding);
|
|
378
|
+
if (!source)
|
|
379
|
+
continue;
|
|
380
|
+
const what = source.signalKey ? `binds input "${field}" to signal "${source.signalKey}" of step "${source.stepKey}"` : `binds input "${field}" to the output of step "${source.stepKey}"`;
|
|
381
|
+
const producerRanks = pipeline.steps.map((candidate, candidateIndex) => candidate.stepKey === source.stepKey ? ranks.get(candidateIndex) ?? candidateIndex : null).filter((rank) => rank !== null);
|
|
382
|
+
if (producerRanks.length === 0) {
|
|
383
|
+
issues.push({
|
|
384
|
+
check: "signal-binding",
|
|
385
|
+
message: `${where} ${what}, but no step with that key is in the pipeline. ${orderHint}`
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (!producerRanks.some((rank) => rank < consumerRank)) {
|
|
390
|
+
issues.push({
|
|
391
|
+
check: "signal-binding",
|
|
392
|
+
message: `${where} ${what}, but that step does not run before it, so the ` + `value will never exist. ${orderHint}`
|
|
393
|
+
});
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (source.signalKey === null)
|
|
397
|
+
continue;
|
|
398
|
+
const available = declaredSignalKeys(source.stepKey, stepsByKey, pipeline.steps);
|
|
399
|
+
if (available === null || available.includes(source.signalKey))
|
|
400
|
+
continue;
|
|
401
|
+
issues.push({
|
|
402
|
+
check: "signal-binding",
|
|
403
|
+
message: `${where} ${what}, but "${source.stepKey}" declares no such signal. ` + `Signals on "${source.stepKey}": ${available.length > 0 ? listPaths([...available].sort()) : "(none)"}.`
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return issues;
|
|
409
|
+
}
|
|
410
|
+
function validateDefinitionSpecs(specs, options = {}) {
|
|
411
|
+
const stepsByKey = new Map;
|
|
412
|
+
for (const step of specs.steps) {
|
|
413
|
+
const existing = stepsByKey.get(step.key);
|
|
414
|
+
if (existing)
|
|
415
|
+
existing.push(step);
|
|
416
|
+
else
|
|
417
|
+
stepsByKey.set(step.key, [step]);
|
|
418
|
+
}
|
|
419
|
+
return [
|
|
420
|
+
...checkSignalSourcePaths(specs.steps),
|
|
421
|
+
...checkHealthChecks(specs.steps),
|
|
422
|
+
...checkRouteTargets(specs.pipelines, options.knownPipelineKeys ?? []),
|
|
423
|
+
...checkSignalBindings(specs.pipelines, stepsByKey)
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
function assertValidDefinitionSpecs(specs, options = {}) {
|
|
427
|
+
const issues = validateDefinitionSpecs(specs, options);
|
|
428
|
+
if (issues.length === 0)
|
|
429
|
+
return;
|
|
430
|
+
const header = issues.length === 1 ? "Definition validation failed:" : `Definition validation failed with ${String(issues.length)} problems:`;
|
|
431
|
+
throw new Error([header, ...issues.map((issue) => ` \u2022 ${issue.message}`)].join(`
|
|
432
|
+
`));
|
|
433
|
+
}
|
|
434
|
+
export {
|
|
435
|
+
validateDefinitionSpecs,
|
|
436
|
+
resolveSourcePath,
|
|
437
|
+
parseSourcePath,
|
|
438
|
+
enumeratePaths,
|
|
439
|
+
assertValidDefinitionSpecs
|
|
440
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** A JSON Schema node. `true` / `false` are the boolean schema forms. */
|
|
2
|
+
export type JsonSchemaNode = boolean | {
|
|
3
|
+
readonly [key: string]: unknown;
|
|
4
|
+
};
|
|
5
|
+
export type PathFailureReason =
|
|
6
|
+
/** The parent is a closed object with no such property. */
|
|
7
|
+
"unknown-property"
|
|
8
|
+
/** The parent is an array and the segment is not a numeric index. */
|
|
9
|
+
| "not-an-array-index"
|
|
10
|
+
/** The parent is a scalar, so it has no members at all. */
|
|
11
|
+
| "scalar-has-no-members";
|
|
12
|
+
export type PathResolution =
|
|
13
|
+
/** The whole path resolves to a node in the schema. */
|
|
14
|
+
{
|
|
15
|
+
readonly kind: "resolved";
|
|
16
|
+
}
|
|
17
|
+
/** Neither provably valid nor provably invalid — the schema is too loose. */
|
|
18
|
+
| {
|
|
19
|
+
readonly kind: "indeterminate";
|
|
20
|
+
} | {
|
|
21
|
+
readonly kind: "invalid";
|
|
22
|
+
/** Dot path of the prefix that did resolve; `""` at the root. */
|
|
23
|
+
readonly resolvedPrefix: string;
|
|
24
|
+
/** The first segment that could not resolve. */
|
|
25
|
+
readonly segment: string;
|
|
26
|
+
readonly reason: PathFailureReason;
|
|
27
|
+
/** Paths that DO resolve below `resolvedPrefix`, relative to it. */
|
|
28
|
+
readonly availablePaths: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
/** Splits a `sourcePath` exactly as the runtime signal extractor does. */
|
|
31
|
+
export declare function parseSourcePath(sourcePath: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Enumerates dot paths that resolve below `node`, for error messages.
|
|
34
|
+
*
|
|
35
|
+
* Depth-limited and count-limited: this is a hint for a human reading a push
|
|
36
|
+
* failure, not an exhaustive schema dump. Arrays are treated as leaves.
|
|
37
|
+
*/
|
|
38
|
+
export declare function enumeratePaths(node: JsonSchemaNode, root: JsonSchemaNode, maxDepth?: number, limit?: number): string[];
|
|
39
|
+
/**
|
|
40
|
+
* Whether `sourcePath` can resolve against `schema`.
|
|
41
|
+
*
|
|
42
|
+
* Pass the step's `resultSchemaJson` as both the node and the document root;
|
|
43
|
+
* `$ref`s are resolved against the root.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveSourcePath(schema: JsonSchemaNode, sourcePath: string): PathResolution;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PipelineDefinitionSpec } from "../pipelines/define-pipeline";
|
|
2
|
+
import type { StepDefinitionSpec } from "../steps/define-step";
|
|
3
|
+
export type DefinitionSpecSet = {
|
|
4
|
+
readonly pipelines: readonly PipelineDefinitionSpec[];
|
|
5
|
+
readonly steps: readonly StepDefinitionSpec[];
|
|
6
|
+
};
|
|
7
|
+
export type ValidateDefinitionSpecsOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* Pipeline keys that exist outside this batch — in practice, the keys already
|
|
10
|
+
* on the server. Route targets may name these as well as keys in
|
|
11
|
+
* `specs.pipelines`. Omit it and only the batch counts.
|
|
12
|
+
*/
|
|
13
|
+
readonly knownPipelineKeys?: readonly string[];
|
|
14
|
+
};
|
|
15
|
+
export type DefinitionValidationIssue = {
|
|
16
|
+
readonly check: "signal-source-path" | "route-target" | "signal-binding" | "health-check-mcp-server" | "health-check-double-qualified";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Runs every offline check over a batch of definitions and returns the issues
|
|
21
|
+
* found, in check order. An empty array means the batch is clean.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateDefinitionSpecs(specs: DefinitionSpecSet, options?: ValidateDefinitionSpecsOptions): DefinitionValidationIssue[];
|
|
24
|
+
/** `validateDefinitionSpecs`, but throws a single aggregated error. */
|
|
25
|
+
export declare function assertValidDefinitionSpecs(specs: DefinitionSpecSet, options?: ValidateDefinitionSpecsOptions): void;
|