agent-relay-server 0.117.1 → 0.118.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/docs/openapi.json +1 -1
- package/package.json +3 -3
- package/public/assets/types-DSwhOFcJ.js.map +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/artifact-text.ts +36 -0
- package/src/auto-spawn-dispatch.ts +136 -0
- package/src/branch-landed.ts +127 -1
- package/src/db/connection.ts +5 -1
- package/src/land-gates.ts +8 -80
- package/src/notification-types.ts +9 -0
- package/src/routes/agents-spawn.ts +35 -6
- package/src/routes/commands.ts +91 -7
- package/src/steward.ts +38 -6
- package/src/task-dispatcher.ts +94 -7
- package/src/utils.ts +7 -38
- package/src/validation.ts +6 -25
package/src/steward.ts
CHANGED
|
@@ -5,6 +5,9 @@ import { getSpawnPolicy, getStewardConfig, setConfig } from "./config-store";
|
|
|
5
5
|
import { listOrchestrators } from "./db";
|
|
6
6
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
7
7
|
import { renderTemplate } from "./prompt-resolver";
|
|
8
|
+
import { resolveInternalSpawnTarget } from "./auto-spawn-dispatch";
|
|
9
|
+
import type { DispatchPin } from "./task-dispatcher";
|
|
10
|
+
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
8
11
|
import type { Orchestrator, SpawnPolicy, StewardConfig } from "./types";
|
|
9
12
|
import { isPathWithinBase } from "./utils";
|
|
10
13
|
|
|
@@ -39,16 +42,27 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
|
|
|
39
42
|
// repo and those worktrees, and its provider-agent token's cwdPrefixes derive
|
|
40
43
|
// from this cwd — baseDir is their common ancestor, so the merge action against a
|
|
41
44
|
// worktree path passes authorization. The prompt scopes it to the specific repo.
|
|
45
|
+
|
|
46
|
+
// #901 — resolve via the auto-spawn dispatcher so routing policy + quota bands pick
|
|
47
|
+
// the model. StewardConfig.provider/model/effort are OPTIONAL EXPLICIT PINS: when set
|
|
48
|
+
// by the operator they fold into the DispatchPin and win over the router's choice.
|
|
49
|
+
const pin: DispatchPin = {
|
|
50
|
+
provider: config.provider,
|
|
51
|
+
...(config.model ? { model: config.model } : {}),
|
|
52
|
+
...(config.effort ? { effort: config.effort } : {}),
|
|
53
|
+
};
|
|
54
|
+
const resolved = resolveInternalSpawnTarget("landing-steward", pin);
|
|
55
|
+
|
|
42
56
|
return {
|
|
43
57
|
name: repoStewardPolicyName(repoRoot),
|
|
44
58
|
description: `Autonomous repo steward for ${repoRoot} (issue #167).`,
|
|
45
59
|
enabled: true,
|
|
46
60
|
orchestratorId: owner.id,
|
|
47
61
|
cwd: owner.baseDir,
|
|
48
|
-
provider:
|
|
62
|
+
provider: resolved.target.provider,
|
|
49
63
|
workspaceMode: "inherit",
|
|
50
|
-
...(
|
|
51
|
-
...(
|
|
64
|
+
...(resolved.target.model ? { model: resolved.target.model } : {}),
|
|
65
|
+
...(resolved.target.effort ? { effort: resolved.target.effort } : {}),
|
|
52
66
|
providerArgs: [],
|
|
53
67
|
prompt: buildStewardPrompt(repoRoot),
|
|
54
68
|
tags: ["steward"],
|
|
@@ -65,18 +79,36 @@ function desiredStewardPolicy(repoRoot: string, owner: Orchestrator, config: Ste
|
|
|
65
79
|
|
|
66
80
|
// Fields whose change means the persisted policy should be refreshed. Avoids
|
|
67
81
|
// churning the config version (and the lifecycle reconcile) every sweep.
|
|
82
|
+
//
|
|
83
|
+
// Churn guard (#901): model-only diffs are suppressed when the stored model is still
|
|
84
|
+
// enabled+available — quota-band oscillation would otherwise change the resolved model on
|
|
85
|
+
// every sweep and trigger a lifecycle reconcile each time. Provider changes ARE compared —
|
|
86
|
+
// a genuine provider switch must always propagate.
|
|
87
|
+
// Self-heal exception: if the stored model is explicitly disabled/unavailable in the
|
|
88
|
+
// effective catalog, allow the diff so the steward refreshes onto an enabled model.
|
|
68
89
|
function stewardPolicyDiffers(existing: SpawnPolicy, desired: SpawnPolicy): boolean {
|
|
69
|
-
|
|
90
|
+
if (
|
|
70
91
|
existing.enabled !== desired.enabled ||
|
|
71
92
|
existing.provider !== desired.provider ||
|
|
72
|
-
existing.model !== desired.model ||
|
|
73
93
|
existing.effort !== desired.effort ||
|
|
74
94
|
existing.permissionMode !== desired.permissionMode ||
|
|
75
95
|
existing.cwd !== desired.cwd ||
|
|
76
96
|
existing.orchestratorId !== desired.orchestratorId ||
|
|
77
97
|
existing.prompt !== desired.prompt ||
|
|
78
98
|
existing.onDemand?.keepaliveSeconds !== desired.onDemand?.keepaliveSeconds
|
|
79
|
-
);
|
|
99
|
+
) return true;
|
|
100
|
+
|
|
101
|
+
// Model-only diff: suppress (anti-thrash) unless the stored model is disabled/unavailable.
|
|
102
|
+
if (existing.model !== desired.model && existing.model) {
|
|
103
|
+
const catalog = effectiveProviderCatalogList();
|
|
104
|
+
const providerEntry = catalog.find((c) => c.provider === existing.provider);
|
|
105
|
+
const storedModel = providerEntry?.models.find((m) => m.alias === existing.model || m.providerModel === existing.model);
|
|
106
|
+
// Only trigger refresh when the model is EXPLICITLY disabled/unavailable in the catalog.
|
|
107
|
+
// If it's simply absent (incomplete catalog data), suppress to avoid false churn.
|
|
108
|
+
if (storedModel && (storedModel.disabled || storedModel.unavailable)) return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
80
112
|
}
|
|
81
113
|
|
|
82
114
|
/**
|
package/src/task-dispatcher.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
DispatchModelTarget,
|
|
20
20
|
DispatchPolicy,
|
|
21
21
|
DispatchRule,
|
|
22
|
+
HostUtilizationSignal,
|
|
22
23
|
SpawnProvider,
|
|
23
24
|
TaskDispatch,
|
|
24
25
|
} from "agent-relay-sdk";
|
|
@@ -42,12 +43,31 @@ export interface DispatchContext {
|
|
|
42
43
|
policy?: DispatchPolicy;
|
|
43
44
|
bands?: ProviderQuotaBand[];
|
|
44
45
|
catalog?: ProviderCatalogEntry[];
|
|
46
|
+
/**
|
|
47
|
+
* #901 degrade-never-refuse: when true, the dispatcher ALWAYS returns a usable provider —
|
|
48
|
+
* widening the search through caution → avoid-large → least-utilized blockedNow tiers before
|
|
49
|
+
* giving up. A loud warning is emitted for each degraded tier used. Non-critical paths (flag
|
|
50
|
+
* unset or false) keep the existing stricter behaviour (prefer|normal only).
|
|
51
|
+
*/
|
|
52
|
+
mustResolveAvailable?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* #901 host-placement seam. Shape-only in v1 — no selection logic reads this field yet.
|
|
55
|
+
* A future host-placement phase will inject live host load here so the dispatcher can
|
|
56
|
+
* prefer the least-loaded machine when multiple orchestrators are viable.
|
|
57
|
+
*/
|
|
58
|
+
hosts?: HostUtilizationSignal[];
|
|
45
59
|
}
|
|
46
60
|
|
|
47
61
|
export interface DispatchTarget {
|
|
48
62
|
provider: SpawnProvider;
|
|
49
63
|
model?: string;
|
|
50
64
|
effort?: string;
|
|
65
|
+
/**
|
|
66
|
+
* #901 host-placement seam. Stays undefined in v1 — a future host-placement phase will
|
|
67
|
+
* populate this with the chosen orchestrator id when multiple hosts are viable.
|
|
68
|
+
* No selection logic writes or reads this field in v1.
|
|
69
|
+
*/
|
|
70
|
+
orchestratorId?: string;
|
|
51
71
|
}
|
|
52
72
|
|
|
53
73
|
/** Structured trace of the inputs that produced the decision (the machine-readable rationale). */
|
|
@@ -131,7 +151,12 @@ export function dispatchTask(shape: TaskDispatch | undefined, pin: DispatchPin =
|
|
|
131
151
|
// Band-aware rerouting (skip when the provider is explicitly pinned). Protects scarce pools
|
|
132
152
|
// and load-shifts large/blocked work onto an abundant one. Re-validates footprint + a pinned
|
|
133
153
|
// effort on the rerouted provider so neither is silently dropped.
|
|
134
|
-
if (!providerPinned) rerouteForBand(target, work, policy, bands, catalog, factors, warnings, { effortPinned });
|
|
154
|
+
if (!providerPinned) rerouteForBand(target, work, policy, bands, catalog, factors, warnings, { effortPinned, mustResolveAvailable: ctx.mustResolveAvailable });
|
|
155
|
+
|
|
156
|
+
// Guarantee the resolved model is enabled. If the routing policy or reroute picked a model
|
|
157
|
+
// that has been disabled/unavailable in the effective catalog, swap to the first enabled model
|
|
158
|
+
// on the chosen provider. Skip when the model was explicitly pinned — caller's choice wins.
|
|
159
|
+
if (!modelPinned) ensureEnabledModel(target, catalog, warnings);
|
|
135
160
|
|
|
136
161
|
// Record the FINAL provider's band + the chosen model's window for transparency.
|
|
137
162
|
factors.band = bandFor(target.provider, bands);
|
|
@@ -248,6 +273,33 @@ function escalateForFootprint(
|
|
|
248
273
|
);
|
|
249
274
|
}
|
|
250
275
|
|
|
276
|
+
/** True when the provider has at least one model that is not disabled or unavailable. */
|
|
277
|
+
function hasEnabledModel(catalog: ProviderCatalogEntry[], provider: SpawnProvider): boolean {
|
|
278
|
+
const entry = catalogEntry(catalog, provider);
|
|
279
|
+
return !!entry && entry.models.some((m) => !m.disabled && !m.unavailable);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Validate that `target.model` is enabled on the chosen provider. When it is disabled or
|
|
284
|
+
* unavailable, replace it with the first enabled model and emit a warning. Callers must skip
|
|
285
|
+
* this when the model was explicitly pinned (pin takes precedence, even if broken).
|
|
286
|
+
*/
|
|
287
|
+
function ensureEnabledModel(target: DispatchTarget, catalog: ProviderCatalogEntry[], warnings: string[]): void {
|
|
288
|
+
const entry = catalogEntry(catalog, target.provider);
|
|
289
|
+
if (!entry) return;
|
|
290
|
+
const alias = target.model ?? entry.defaultModel;
|
|
291
|
+
if (!alias) return;
|
|
292
|
+
const model = entry.models.find((m) => m.alias === alias || m.providerModel === alias);
|
|
293
|
+
if (model && !model.disabled && !model.unavailable) return;
|
|
294
|
+
const enabled = entry.models.find((m) => !m.disabled && !m.unavailable);
|
|
295
|
+
if (enabled) {
|
|
296
|
+
warnings.push(`Model ${alias} is disabled/unavailable on ${target.provider}; resolved to ${enabled.alias} instead.`);
|
|
297
|
+
target.model = enabled.alias;
|
|
298
|
+
} else {
|
|
299
|
+
warnings.push(`No enabled models on ${target.provider}; spawn may fail.`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
251
303
|
// The smallest-window model on this provider whose window meets the requirement. Prefers the
|
|
252
304
|
// same model family as `alias` (e.g. opus-4.8 → opus-4.8[1m]) when one qualifies, else any family.
|
|
253
305
|
function biggestSufficient(
|
|
@@ -281,7 +333,7 @@ function rerouteForBand(
|
|
|
281
333
|
catalog: ProviderCatalogEntry[],
|
|
282
334
|
factors: DispatchFactors,
|
|
283
335
|
warnings: string[],
|
|
284
|
-
opts: { effortPinned: boolean },
|
|
336
|
+
opts: { effortPinned: boolean; mustResolveAvailable?: boolean },
|
|
285
337
|
): void {
|
|
286
338
|
const band = bandFor(target.provider, bands);
|
|
287
339
|
const blocked = isBlocked(target.provider, bands);
|
|
@@ -290,11 +342,16 @@ function rerouteForBand(
|
|
|
290
342
|
const mustMove = blocked || band === "hard-avoid" || (isLarge && band === "avoid-large");
|
|
291
343
|
if (!mustMove) return;
|
|
292
344
|
|
|
293
|
-
const alt = healthiestAlternative(target.provider, bands, catalog);
|
|
345
|
+
const alt = healthiestAlternative(target.provider, bands, catalog, opts.mustResolveAvailable);
|
|
294
346
|
if (!alt) {
|
|
295
347
|
warnings.push(`${target.provider} band=${band}${blocked ? " (at capacity)" : ""} and no healthier provider is available — keeping it; expect contention.`);
|
|
296
348
|
return;
|
|
297
349
|
}
|
|
350
|
+
if (alt.degraded) {
|
|
351
|
+
warnings.push(
|
|
352
|
+
`[CRITICAL] mustResolveAvailable: all prefer/normal providers unavailable; degraded to ${alt.provider} (${alt.degradedReason ?? "degraded tier"}). Contention likely.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
298
355
|
const altEntry = catalogEntry(catalog, alt.provider)!;
|
|
299
356
|
factors.reroutedFromProvider = target.provider;
|
|
300
357
|
warnings.push(`Rerouted ${target.provider} (band=${band}${blocked ? ", at capacity" : ""}) → ${alt.provider} (band=${alt.band}) to ${alt.band === "prefer" ? "use abundant quota" : "avoid the scarce pool"}.`);
|
|
@@ -323,18 +380,48 @@ function rerouteForBand(
|
|
|
323
380
|
// catalog — not only those with a quota advisory. A provider with no quota record defaults to the
|
|
324
381
|
// "normal" band (via bandFor), so a healthy catalog provider is never skipped just because it has
|
|
325
382
|
// no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
|
|
383
|
+
//
|
|
384
|
+
// When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
|
|
385
|
+
// no prefer/normal provider is available: caution → avoid-large → least-utilized blockedNow.
|
|
386
|
+
// A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
|
|
326
387
|
function healthiestAlternative(
|
|
327
388
|
avoid: SpawnProvider,
|
|
328
389
|
bands: ProviderQuotaBand[],
|
|
329
390
|
catalog: ProviderCatalogEntry[],
|
|
330
|
-
|
|
331
|
-
|
|
391
|
+
mustResolveAvailable?: boolean,
|
|
392
|
+
): { provider: SpawnProvider; band: QuotaBand; degraded?: boolean; degradedReason?: string } | undefined {
|
|
393
|
+
const all = catalog
|
|
332
394
|
.map((c) => c.provider)
|
|
333
395
|
.filter((provider) => provider !== avoid)
|
|
334
|
-
.
|
|
396
|
+
.filter((provider) => hasEnabledModel(catalog, provider)) // skip providers with all models disabled
|
|
397
|
+
.map((provider) => ({ provider, band: bandFor(provider, bands), blockedNow: isBlocked(provider, bands) }));
|
|
398
|
+
|
|
399
|
+
const standard = all
|
|
335
400
|
.filter((c) => !c.blockedNow && (c.band === "prefer" || c.band === "normal"))
|
|
336
401
|
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
337
|
-
return
|
|
402
|
+
if (standard[0]) return standard[0];
|
|
403
|
+
|
|
404
|
+
if (!mustResolveAvailable) return undefined;
|
|
405
|
+
|
|
406
|
+
// Tier 1 degradation: caution (over quota but not avoid/blocked)
|
|
407
|
+
const cautionAlt = all
|
|
408
|
+
.filter((c) => !c.blockedNow && c.band === "caution")
|
|
409
|
+
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
410
|
+
if (cautionAlt[0]) return { ...cautionAlt[0], degraded: true, degradedReason: "caution band (tier 1 degraded)" };
|
|
411
|
+
|
|
412
|
+
// Tier 2 degradation: avoid-large
|
|
413
|
+
const avoidLargeAlt = all
|
|
414
|
+
.filter((c) => !c.blockedNow && c.band === "avoid-large")
|
|
415
|
+
.sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
416
|
+
if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
|
|
417
|
+
|
|
418
|
+
// Tier 3 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
|
|
419
|
+
// Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
|
|
420
|
+
// not whichever happens to be earliest in catalog order.
|
|
421
|
+
const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
|
|
422
|
+
if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 3 last-resort — ALL providers at capacity)" };
|
|
423
|
+
|
|
424
|
+
return undefined;
|
|
338
425
|
}
|
|
339
426
|
|
|
340
427
|
function collectBandWarnings(provider: SpawnProvider, work: TaskDispatch, bands: ProviderQuotaBand[], providerPinned: boolean, warnings: string[]): void {
|
package/src/utils.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { join } from "node:path";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
|
|
4
|
+
// `isPathWithinBase` and `parseJson` now live in the SDK (agent-relay-sdk) as the
|
|
5
|
+
// single canonical home shared with the orchestrator package; re-exported here so
|
|
6
|
+
// the server's existing importers stay byte-unchanged. Never re-declare them — the
|
|
7
|
+
// path-containment helper in particular has a history of duplicated-copy bugs.
|
|
8
|
+
export { isPathWithinBase, parseJson } from "agent-relay-sdk";
|
|
9
|
+
|
|
4
10
|
/**
|
|
5
11
|
* Expand a leading `~` / `~/` to the current user's home directory. Leaves any
|
|
6
12
|
* other value (absolute paths, `./rel`, embedded `~` mid-string) untouched.
|
|
@@ -14,40 +20,3 @@ export function expandTilde(value: string): string {
|
|
|
14
20
|
if (value.startsWith("~/")) return join(homedir(), value.slice(2));
|
|
15
21
|
return value;
|
|
16
22
|
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Path containment check — is `path` inside (or equal to) `baseDir`?
|
|
20
|
-
*
|
|
21
|
-
* Both inputs are `resolve()`d first for defensive clarity. (Note: `path.relative`
|
|
22
|
-
* already `resolve()`s both args against cwd internally, so this is belt-and-
|
|
23
|
-
* suspenders, not a behavior change — verified: the `security.ts` copy that
|
|
24
|
-
* omitted the explicit `resolve()` produced identical results. The audit's
|
|
25
|
-
* "bug #4" was a false alarm.) Returns `false` when either input is missing.
|
|
26
|
-
*
|
|
27
|
-
* Was hand-copied in 6 server modules (security, workspace-merge, mcp,
|
|
28
|
-
* lifecycle-manager, steward, routes) under three different names. Import this;
|
|
29
|
-
* never re-declare it.
|
|
30
|
-
*/
|
|
31
|
-
export function isPathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
|
|
32
|
-
if (!path || !baseDir) return false;
|
|
33
|
-
const rel = relative(resolve(baseDir), resolve(path));
|
|
34
|
-
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Parse JSON, returning `fallback` on any failure (non-string input, empty
|
|
39
|
-
* string, or malformed JSON). Never throws.
|
|
40
|
-
*
|
|
41
|
-
* Folds the four fallback-style `parseJson` copies (db, memory-sqlite-broker,
|
|
42
|
-
* provider-catalog-store, automations). The throwing / Buffer / null-returning
|
|
43
|
-
* variants (memory-*-broker operation-context, control-server, insights-db) are
|
|
44
|
-
* deliberately separate — different contracts.
|
|
45
|
-
*/
|
|
46
|
-
export function parseJson<T>(raw: unknown, fallback: T): T {
|
|
47
|
-
if (typeof raw !== "string" || !raw) return fallback;
|
|
48
|
-
try {
|
|
49
|
-
return JSON.parse(raw) as T;
|
|
50
|
-
} catch {
|
|
51
|
-
return fallback;
|
|
52
|
-
}
|
|
53
|
-
}
|
package/src/validation.ts
CHANGED
|
@@ -1,33 +1,14 @@
|
|
|
1
1
|
import { ValidationError } from "./db/connection.ts";
|
|
2
2
|
import { type TokenConstraints, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMetadata, type WorkspaceMode, type WorkspaceProbe, type WorkspaceStatus } from "./types";
|
|
3
|
-
import { AUTO_MERGE_POLICIES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
|
|
3
|
+
import { AUTO_MERGE_POLICIES, VALID_WORKSPACE_MODES, cleanString, isRecord } from "agent-relay-sdk";
|
|
4
4
|
|
|
5
5
|
const WORKSPACE_LAND_STRATEGIES = ["direct", "pr"] as const;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* Single home — was byte-identical in config-store, automations, and routes.
|
|
13
|
-
*/
|
|
14
|
-
export function cleanString(
|
|
15
|
-
value: unknown,
|
|
16
|
-
field: string,
|
|
17
|
-
opts: { required?: boolean; max?: number } = {},
|
|
18
|
-
): string | undefined {
|
|
19
|
-
if (value === undefined || value === null) {
|
|
20
|
-
if (opts.required) throw new ValidationError(`${field} required`);
|
|
21
|
-
return undefined;
|
|
22
|
-
}
|
|
23
|
-
if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
|
|
24
|
-
const trimmed = value.trim();
|
|
25
|
-
if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
|
|
26
|
-
if (opts.max && trimmed.length > opts.max) {
|
|
27
|
-
throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
|
|
28
|
-
}
|
|
29
|
-
return trimmed || undefined;
|
|
30
|
-
}
|
|
7
|
+
// `cleanString` now lives in the SDK (agent-relay-sdk) as the single canonical home
|
|
8
|
+
// shared with the orchestrator package; imported above for the local validators here and
|
|
9
|
+
// re-exported so existing importers stay byte-unchanged. The other validators keep their
|
|
10
|
+
// server home.
|
|
11
|
+
export { cleanString };
|
|
31
12
|
|
|
32
13
|
/**
|
|
33
14
|
* Validate a REQUIRED enum value: throws `ValidationError` if missing or not one
|