switchback-core 2026.10.14 → 2026.10.15
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/package.json +2 -1
- package/src/availability.ts +5 -4
- package/src/classify.ts +14 -11
- package/src/config-editor.ts +85 -107
- package/src/config.ts +202 -183
- package/src/context-fit.ts +3 -3
- package/src/dispatch.ts +1 -1
- package/src/failure.ts +6 -6
- package/src/idle.ts +3 -3
- package/src/index.ts +22 -5
- package/src/routing.ts +56 -18
- package/src/simulate.ts +10 -2
- package/src/state.ts +63 -1
- package/src/systemone.ts +52 -46
- package/src/thinking.ts +2 -2
- package/src/types.ts +56 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchback-core",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Host-agnostic core for switchback: fallback routing, error classification ports, block state and configuration.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@typesafe-ai/sdk": "^0.7.0",
|
|
32
33
|
"yaml": "^2.9.1"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
package/src/availability.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { AvailabilityPort, HostModel } from "./ports.ts";
|
|
22
|
-
import type { Availability, BlockedMap, ModelId, ResolvedEntry, ResolvedFallbacks } from "./types.ts";
|
|
22
|
+
import type { Availability, BlockedMap, DisabledMap, ModelId, ResolvedEntry, ResolvedFallbacks } from "./types.ts";
|
|
23
23
|
|
|
24
24
|
function splitModelId(modelId: ModelId): { provider: string; id: string } | undefined {
|
|
25
25
|
const slash = modelId.indexOf("/");
|
|
@@ -124,6 +124,7 @@ export function pickNextEffective(
|
|
|
124
124
|
now: number,
|
|
125
125
|
startAfter?: ModelId,
|
|
126
126
|
excluded?: ReadonlySet<ModelId>,
|
|
127
|
+
disabled?: DisabledMap,
|
|
127
128
|
): { modelId: ModelId; degraded: boolean } | undefined {
|
|
128
129
|
const effective = effectiveIds(resolved);
|
|
129
130
|
if (effective.length === 0) return undefined;
|
|
@@ -132,14 +133,14 @@ export function pickNextEffective(
|
|
|
132
133
|
for (let i = 0; i < count; i++) {
|
|
133
134
|
const candidate = effective[(start + i) % count]!;
|
|
134
135
|
if (candidate === skip || excluded?.has(candidate) === true) continue;
|
|
136
|
+
if (disabled?.[candidate] !== undefined) continue;
|
|
135
137
|
const blockedUntil = blocked[candidate];
|
|
136
138
|
if (blockedUntil !== undefined && blockedUntil > now) continue;
|
|
137
139
|
return { modelId: candidate, degraded: false };
|
|
138
140
|
}
|
|
139
141
|
// Degraded mode: only when exactly one effective entry exists. Edge case #4.
|
|
140
|
-
// An excluded
|
|
141
|
-
|
|
142
|
-
if (count === 1 && excluded?.has(effective[0]!) !== true) {
|
|
142
|
+
// An excluded or manually disabled model is never offered, not even degraded.
|
|
143
|
+
if (count === 1 && excluded?.has(effective[0]!) !== true && disabled?.[effective[0]!] === undefined) {
|
|
143
144
|
return { modelId: effective[0]!, degraded: true };
|
|
144
145
|
}
|
|
145
146
|
// Multiple effective entries all blocked: return undefined so the router
|
package/src/classify.ts
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* Kept deliberately: the directive explicitly allows typed signals from
|
|
34
34
|
* pi, only the message-derived heuristics are forbidden.
|
|
35
35
|
*
|
|
36
|
-
* 2. CLASSIFIER: the configured `
|
|
36
|
+
* 2. CLASSIFIER: the configured `decisionModel` entry via `ctx.modelRegistry.classify`.
|
|
37
37
|
* The structured-output answers (class / reset score / scope) are the
|
|
38
38
|
* ONLY message-derived decisions. Score is mapped to ms via
|
|
39
39
|
* `scoreToResetAtMs` (rubric index, 31d cap). Timeout, parse error,
|
|
@@ -90,7 +90,7 @@ const MAX_RESET_MS = 31 * 24 * 3_600_000;
|
|
|
90
90
|
* v4 (2026-10-06): the `scope` question now anchors each option in wording the
|
|
91
91
|
* message actually contains. v3 asked the model to judge counterfactuals ("other
|
|
92
92
|
* models still work") and named only `unknown` in its instructions, so `unknown`
|
|
93
|
-
* took the prior mass and won on every case measured - `typesafe/
|
|
93
|
+
* took the prior mass and won on every case measured - `typesafe/decisionModel-latest`
|
|
94
94
|
* answered `unknown` at 84-92% with `account` second. The new text gives the
|
|
95
95
|
* model the lexical cues an error message really carries (a named model, a key
|
|
96
96
|
* or plan, an address) and asks for `unknown` only as a last resort. Verdicts
|
|
@@ -104,7 +104,7 @@ const MAX_RESET_MS = 31 * 24 * 3_600_000;
|
|
|
104
104
|
* model or series / a credential, plan, balance or usage quota / an address or
|
|
105
105
|
* region), which is a property of the question rather than of any provider.
|
|
106
106
|
* Measured before and after on two held-out messages that were never in the
|
|
107
|
-
* corpus: `typesafe/
|
|
107
|
+
* corpus: `typesafe/decisionModel-latest` 7/7 with and without the examples, so the
|
|
108
108
|
* hosted classifier never needed them; `parable/tinyjev:latest` drops 7/7 ->
|
|
109
109
|
* 5/7, losing `weekly token limit exhausted` and `monthly cap`, which the
|
|
110
110
|
* smaller model needed a concrete anchor for. The hosted path is what the
|
|
@@ -239,9 +239,12 @@ function normaliseScope(value: unknown): ErrorScope {
|
|
|
239
239
|
return "unknown";
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
/** Find a classifier in the model registry. The provider+id are looked up via
|
|
243
|
-
function findJev(
|
|
244
|
-
|
|
242
|
+
/** Find a classifier in the model registry. The provider+id are looked up via DecisionModelConfig. */
|
|
243
|
+
function findJev(
|
|
244
|
+
registry: ClassifierPort,
|
|
245
|
+
decisionModel: { provider: string; id: string },
|
|
246
|
+
): ClassifierEntry | undefined {
|
|
247
|
+
return registry.findOfType("classifier", decisionModel.provider, decisionModel.id);
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
/**
|
|
@@ -340,14 +343,14 @@ export type ClassifierCallResult =
|
|
|
340
343
|
*/
|
|
341
344
|
export async function callClassifier(
|
|
342
345
|
registry: ClassifierPort,
|
|
343
|
-
|
|
346
|
+
decisionModel: { provider: string; id: string } | undefined,
|
|
344
347
|
context: ClassifierContext,
|
|
345
348
|
timeoutMs: number = CLASSIFIER_TIMEOUT_MS,
|
|
346
349
|
): Promise<ClassifierCallResult> {
|
|
347
|
-
if (
|
|
350
|
+
if (decisionModel === undefined) {
|
|
348
351
|
return { kind: "no-classifier", reason: "not-configured" };
|
|
349
352
|
}
|
|
350
|
-
const handle = findJev(registry,
|
|
353
|
+
const handle = findJev(registry, decisionModel);
|
|
351
354
|
if (handle === undefined) {
|
|
352
355
|
return { kind: "no-classifier", reason: "unresolvable" };
|
|
353
356
|
}
|
|
@@ -408,7 +411,7 @@ export function readConfidence(result: ClassifierResult, questionId: string): nu
|
|
|
408
411
|
export async function classifyError(
|
|
409
412
|
message: string,
|
|
410
413
|
registry: ClassifierPort,
|
|
411
|
-
|
|
414
|
+
decisionModel: { provider: string; id: string } | undefined,
|
|
412
415
|
now: number = Date.now(),
|
|
413
416
|
stopReason?: StopReason,
|
|
414
417
|
): Promise<ClassificationResult> {
|
|
@@ -442,7 +445,7 @@ export async function classifyError(
|
|
|
442
445
|
|
|
443
446
|
// Branch 2: classifier. Any failure (not configured, unresolvable,
|
|
444
447
|
// timeout, threw, unparseable) falls through to "no-classifier".
|
|
445
|
-
const call = await callClassifier(registry,
|
|
448
|
+
const call = await callClassifier(registry, decisionModel, {
|
|
446
449
|
state: classifierState(message),
|
|
447
450
|
questions: JEV_QUESTIONS,
|
|
448
451
|
});
|
package/src/config-editor.ts
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* Validation lives in exactly one place. Every mutation is structural only
|
|
12
12
|
* (locate a node, set/splice/delete); `saveLayer()` re-validates the whole
|
|
13
13
|
* document through the exported `validateFileConfig()` - the same gate the
|
|
14
|
-
* loader applies
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* would accept, or throws the loader's own ConfigError messages.
|
|
14
|
+
* loader applies. The editor therefore carries no rule set of its own: a save
|
|
15
|
+
* either produces exactly what `loadConfig()` would accept, or throws the
|
|
16
|
+
* loader's own ConfigError messages.
|
|
18
17
|
*
|
|
19
18
|
* Secrets: API keys never enter the file as literals. The decision-model
|
|
20
19
|
* operations accept a secret NAME (`apiKeySecretName`), and the value is
|
|
@@ -36,8 +35,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
36
35
|
import { join } from "node:path";
|
|
37
36
|
import { writeFileAtomic } from "./atomic-write.ts";
|
|
38
37
|
import { isMap, isSeq, parseDocument, type Document, type YAMLMap, type YAMLSeq } from "yaml";
|
|
39
|
-
import { ConfigError,
|
|
40
|
-
import type {
|
|
38
|
+
import { ConfigError, piConfigDir, validateFileConfig } from "./config.ts";
|
|
39
|
+
import type { ModelId, SwitchbackFileConfig } from "./types.ts";
|
|
41
40
|
|
|
42
41
|
/** Which switchback.yaml layer the editor has open. */
|
|
43
42
|
export type ConfigLayer = "global" | "project";
|
|
@@ -130,7 +129,7 @@ export function loadLayer(layer: ConfigLayer): LoadedLayer {
|
|
|
130
129
|
* an invalid state.
|
|
131
130
|
*/
|
|
132
131
|
export function readLayerConfig(loaded: LoadedLayer): SwitchbackFileConfig {
|
|
133
|
-
return validateFileConfig(loaded.doc.toJSON(), loaded.path);
|
|
132
|
+
return validateFileConfig(loaded.doc.toJSON(), loaded.path).config;
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
function errorMessage(error: unknown): string {
|
|
@@ -262,159 +261,146 @@ export function moveFallback(loaded: LoadedLayer, virtualId: string, from: numbe
|
|
|
262
261
|
}
|
|
263
262
|
|
|
264
263
|
/**
|
|
265
|
-
* Set or clear a model's
|
|
266
|
-
* reports and blind-cycles without classifying)
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
264
|
+
* Set or clear a model's decision model. `null` clears the key (the model then
|
|
265
|
+
* reports and blind-cycles without classifying). The value is a decision-model
|
|
266
|
+
* address ("provider/model-id"); connection details, when the address needs a
|
|
267
|
+
* direct endpoint, live in the top-level `decisionModels:` map under the same
|
|
268
|
+
* address.
|
|
270
269
|
*/
|
|
271
|
-
export function setDecisionModel(
|
|
272
|
-
loaded: LoadedLayer,
|
|
273
|
-
virtualId: string,
|
|
274
|
-
value: JevConfig | { decisionModel: string } | null,
|
|
275
|
-
): void {
|
|
270
|
+
export function setDecisionModel(loaded: LoadedLayer, virtualId: string, address: string | null): void {
|
|
276
271
|
const { node } = findModelNode(loaded, virtualId);
|
|
277
|
-
if (
|
|
278
|
-
node.delete("
|
|
272
|
+
if (address === null) {
|
|
273
|
+
node.delete("decisionModel");
|
|
279
274
|
return;
|
|
280
275
|
}
|
|
281
|
-
node.set("
|
|
276
|
+
node.set("decisionModel", address);
|
|
282
277
|
}
|
|
283
278
|
|
|
284
|
-
/** A decision-model entry as the editor accepts it
|
|
279
|
+
/** A decision-model entry as the editor accepts it. */
|
|
285
280
|
export interface DecisionModelInput {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
281
|
+
/** "provider/model-id" - the map key, and the value a chain names. */
|
|
282
|
+
address: string;
|
|
283
|
+
/** Direct SystemOne endpoint; omit for catalog resolution. */
|
|
289
284
|
baseUrl?: string;
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
/**
|
|
286
|
+
* Secret-store name, written as `secret:<name>`. Omit for an endpoint that
|
|
287
|
+
* ignores the bearer token; the entry then carries `apiKey: local`.
|
|
288
|
+
*/
|
|
292
289
|
apiKeySecretName?: string;
|
|
293
290
|
}
|
|
294
291
|
|
|
295
|
-
/** Field-level patch for an existing decision model; `null` clears
|
|
292
|
+
/** Field-level patch for an existing decision model; `null` clears the field. */
|
|
296
293
|
export interface DecisionModelPatch {
|
|
297
|
-
|
|
298
|
-
id?: string;
|
|
294
|
+
/** `null` also clears `apiKey` (the loader rule: it requires baseUrl). */
|
|
299
295
|
baseUrl?: string | null;
|
|
300
|
-
|
|
296
|
+
/** `null` writes `apiKey: local` - only meaningful with a baseUrl. */
|
|
301
297
|
apiKeySecretName?: string | null;
|
|
302
298
|
}
|
|
303
299
|
|
|
304
|
-
/** A
|
|
305
|
-
|
|
300
|
+
/** A `decisionModels:` map value as the parser produces it. */
|
|
301
|
+
interface DecisionModelNode {
|
|
302
|
+
baseUrl?: string;
|
|
303
|
+
apiKey?: string;
|
|
304
|
+
}
|
|
306
305
|
|
|
307
306
|
function decisionModelToNode(doc: Document, input: DecisionModelInput): YAMLMap {
|
|
308
307
|
const entry: DecisionModelNode = {
|
|
309
|
-
name: input.name,
|
|
310
|
-
provider: input.provider,
|
|
311
|
-
id: input.id,
|
|
312
308
|
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
|
|
313
|
-
...(input.
|
|
314
|
-
|
|
309
|
+
...(input.baseUrl !== undefined
|
|
310
|
+
? { apiKey: input.apiKeySecretName === undefined ? "local" : `secret:${input.apiKeySecretName}` }
|
|
311
|
+
: {}),
|
|
315
312
|
};
|
|
316
313
|
return doc.createNode(entry) as YAMLMap;
|
|
317
314
|
}
|
|
318
315
|
|
|
319
|
-
function
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
const created = loaded.doc.createNode(
|
|
316
|
+
function requireDecisionModelsMap(loaded: LoadedLayer): YAMLMap {
|
|
317
|
+
const map = loaded.doc.get("decisionModels");
|
|
318
|
+
if (map === undefined || map === null) {
|
|
319
|
+
const created = loaded.doc.createNode({}) as YAMLMap;
|
|
323
320
|
loaded.doc.set("decisionModels", created);
|
|
324
321
|
return created;
|
|
325
322
|
}
|
|
326
|
-
if (!
|
|
327
|
-
|
|
323
|
+
if (!isMap(map)) {
|
|
324
|
+
throw new ConfigError('decisionModels must be a map keyed by "provider/model-id"', loaded.path);
|
|
325
|
+
}
|
|
326
|
+
return map;
|
|
328
327
|
}
|
|
329
328
|
|
|
330
|
-
/** Find a decisionModels entry node by
|
|
331
|
-
function findDecisionModelNode(loaded: LoadedLayer,
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
329
|
+
/** Find a decisionModels entry node by its address (the map key). */
|
|
330
|
+
function findDecisionModelNode(loaded: LoadedLayer, address: string): { map: YAMLMap; node: YAMLMap } {
|
|
331
|
+
const map = requireDecisionModelsMap(loaded);
|
|
332
|
+
const node = map.get(address, true);
|
|
333
|
+
if (node === undefined || node === null || !isMap(node)) {
|
|
334
|
+
throw new ConfigError(`decisionModels.${address} not found`, loaded.path);
|
|
336
335
|
}
|
|
337
|
-
|
|
336
|
+
return { map, node };
|
|
338
337
|
}
|
|
339
338
|
|
|
340
|
-
/**
|
|
339
|
+
/** Add a decision-model entry (duplicate addresses fail the save via `validateFileConfig`). */
|
|
341
340
|
export function addDecisionModel(loaded: LoadedLayer, input: DecisionModelInput): void {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
throw new ConfigError(`decisionModels contains the name "${input.name}" more than once`, loaded.path);
|
|
346
|
-
}
|
|
341
|
+
const map = requireDecisionModelsMap(loaded);
|
|
342
|
+
if (map.has(input.address)) {
|
|
343
|
+
throw new ConfigError(`decisionModels already contains "${input.address}"`, loaded.path);
|
|
347
344
|
}
|
|
348
|
-
|
|
345
|
+
map.set(input.address, decisionModelToNode(loaded.doc, input));
|
|
349
346
|
}
|
|
350
347
|
|
|
351
|
-
/** Patch one decision model's
|
|
352
|
-
export function updateDecisionModel(loaded: LoadedLayer,
|
|
353
|
-
const { node } = findDecisionModelNode(loaded,
|
|
354
|
-
if (patch.provider !== undefined) node.set("provider", patch.provider);
|
|
355
|
-
if (patch.id !== undefined) node.set("id", patch.id);
|
|
348
|
+
/** Patch one decision model's connection details. */
|
|
349
|
+
export function updateDecisionModel(loaded: LoadedLayer, address: string, patch: DecisionModelPatch): void {
|
|
350
|
+
const { node } = findDecisionModelNode(loaded, address);
|
|
356
351
|
if (patch.baseUrl !== undefined) {
|
|
357
352
|
if (patch.baseUrl === null) {
|
|
358
353
|
node.delete("baseUrl");
|
|
359
|
-
//
|
|
360
|
-
node.delete("api");
|
|
354
|
+
// apiKey is only valid alongside baseUrl; clearing baseUrl clears it.
|
|
361
355
|
node.delete("apiKey");
|
|
362
356
|
} else {
|
|
363
357
|
node.set("baseUrl", patch.baseUrl);
|
|
358
|
+
if (!node.has("apiKey")) node.set("apiKey", "local");
|
|
364
359
|
}
|
|
365
360
|
}
|
|
366
|
-
if (patch.api !== undefined) {
|
|
367
|
-
if (patch.api === null) node.delete("api");
|
|
368
|
-
else node.set("api", patch.api);
|
|
369
|
-
}
|
|
370
361
|
if (patch.apiKeySecretName !== undefined) {
|
|
371
|
-
|
|
372
|
-
else node.set("apiKey", `secret:${patch.apiKeySecretName}`);
|
|
362
|
+
node.set("apiKey", patch.apiKeySecretName === null ? "local" : `secret:${patch.apiKeySecretName}`);
|
|
373
363
|
}
|
|
374
364
|
}
|
|
375
365
|
|
|
376
366
|
/**
|
|
377
|
-
* Rename a decision
|
|
378
|
-
* it in the same transaction, so a rename cannot dangle references.
|
|
367
|
+
* Rename a decision-model address and rewrite every `models[].decisionModel`
|
|
368
|
+
* that names it in the same transaction, so a rename cannot dangle references.
|
|
379
369
|
*/
|
|
380
|
-
export function renameDecisionModel(loaded: LoadedLayer,
|
|
381
|
-
if (
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
throw new ConfigError(`decisionModels contains the name "${newName}" more than once`, loaded.path);
|
|
386
|
-
}
|
|
370
|
+
export function renameDecisionModel(loaded: LoadedLayer, oldAddress: string, newAddress: string): void {
|
|
371
|
+
if (oldAddress !== newAddress) {
|
|
372
|
+
const { map, node } = findDecisionModelNode(loaded, oldAddress);
|
|
373
|
+
if (map.has(newAddress)) {
|
|
374
|
+
throw new ConfigError(`decisionModels already contains "${newAddress}"`, loaded.path);
|
|
387
375
|
}
|
|
376
|
+
map.delete(oldAddress);
|
|
377
|
+
map.set(newAddress, node);
|
|
388
378
|
}
|
|
389
|
-
const { node } = findDecisionModelNode(loaded, oldName);
|
|
390
|
-
node.set("name", newName);
|
|
391
379
|
for (const item of requireModelsSeq(loaded).items) {
|
|
392
380
|
if (item === null || !isMap(item)) continue;
|
|
393
|
-
|
|
394
|
-
if (jev === null || jev === undefined || !isMap(jev)) continue;
|
|
395
|
-
if (jev.get("decisionModel") === oldName) jev.set("decisionModel", newName);
|
|
381
|
+
if (item.get("decisionModel") === oldAddress) item.set("decisionModel", newAddress);
|
|
396
382
|
}
|
|
397
383
|
}
|
|
398
384
|
|
|
399
385
|
/**
|
|
400
|
-
* Remove a decision model. References are NOT rewritten: a
|
|
401
|
-
* names the removed
|
|
402
|
-
*
|
|
403
|
-
*
|
|
386
|
+
* Remove a decision model. References are NOT rewritten: a chain that still
|
|
387
|
+
* names the removed address falls back to catalog resolution, which pi can do
|
|
388
|
+
* and OpenCode cannot - the user decides what happens to the referencing model
|
|
389
|
+
* instead of the editor silently clearing it.
|
|
404
390
|
*/
|
|
405
|
-
export function removeDecisionModel(loaded: LoadedLayer,
|
|
406
|
-
const {
|
|
407
|
-
|
|
391
|
+
export function removeDecisionModel(loaded: LoadedLayer, address: string): void {
|
|
392
|
+
const { map } = findDecisionModelNode(loaded, address);
|
|
393
|
+
map.delete(address);
|
|
394
|
+
// An empty map is litter: drop the key so the file reads as if no entry was ever made.
|
|
395
|
+
if (map.items.length === 0) loaded.doc.delete("decisionModels");
|
|
408
396
|
}
|
|
409
397
|
|
|
410
|
-
/** How many configured models
|
|
411
|
-
export function countDecisionModelReferences(loaded: LoadedLayer,
|
|
398
|
+
/** How many configured models name a decision model (the dialogue's confirm/warning text). */
|
|
399
|
+
export function countDecisionModelReferences(loaded: LoadedLayer, address: string): number {
|
|
412
400
|
let count = 0;
|
|
413
401
|
for (const item of requireModelsSeq(loaded).items) {
|
|
414
402
|
if (item === null || !isMap(item)) continue;
|
|
415
|
-
|
|
416
|
-
if (jev === null || jev === undefined || !isMap(jev)) continue;
|
|
417
|
-
if (jev.get("decisionModel") === name) count += 1;
|
|
403
|
+
if (item.get("decisionModel") === address) count += 1;
|
|
418
404
|
}
|
|
419
405
|
return count;
|
|
420
406
|
}
|
|
@@ -427,25 +413,17 @@ export function setDebug(loaded: LoadedLayer, enabled: boolean): void {
|
|
|
427
413
|
/**
|
|
428
414
|
* Validate the mutated document through the loader's single gate and write it
|
|
429
415
|
* atomically. Throws ConfigError (the loader's own messages) when the edited
|
|
430
|
-
* state is not a config `loadConfig()` would accept
|
|
431
|
-
*
|
|
432
|
-
* `resolveJevConfig`. On a throw the in-memory document keeps the edit so the
|
|
433
|
-
* dialogue can re-enter the same editor for a fix.
|
|
416
|
+
* state is not a config `loadConfig()` would accept. On a throw the in-memory
|
|
417
|
+
* document keeps the edit so the dialogue can re-enter the same editor for a fix.
|
|
434
418
|
*/
|
|
435
419
|
export function saveLayer(loaded: LoadedLayer): void {
|
|
436
420
|
if (loaded.doc.contents === undefined || loaded.doc.contents === null) {
|
|
437
421
|
loaded.doc.contents = loaded.doc.createNode({});
|
|
438
422
|
}
|
|
439
|
-
|
|
440
|
-
for (const model of parsed.models) {
|
|
441
|
-
resolveJevConfig(parsed.decisionModels, model.jev as JevRef | undefined);
|
|
442
|
-
}
|
|
423
|
+
validateFileConfig(loaded.doc.toJSON(), loaded.path);
|
|
443
424
|
try {
|
|
444
425
|
writeFileAtomic(loaded.path, loaded.doc.toString());
|
|
445
426
|
} catch (error) {
|
|
446
427
|
throw new ConfigError(`cannot write config: ${errorMessage(error)}`, loaded.path);
|
|
447
428
|
}
|
|
448
429
|
}
|
|
449
|
-
|
|
450
|
-
/** The wire APIs a direct-endpoint decision model may use (re-exported for the dialogue menu). */
|
|
451
|
-
export { LOCAL_CLASSIFIER_APIS };
|