@intentius/chant 0.66.1 → 0.68.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/dist/attrref.d.ts +21 -0
- package/dist/attrref.d.ts.map +1 -1
- package/dist/behaviour-engine.d.ts +212 -0
- package/dist/behaviour-engine.d.ts.map +1 -0
- package/dist/behaviour-http.d.ts +1 -1
- package/dist/behaviour-http.d.ts.map +1 -1
- package/dist/behaviour-kinds.d.ts +220 -0
- package/dist/behaviour-kinds.d.ts.map +1 -0
- package/dist/behaviour-predict.d.ts +83 -0
- package/dist/behaviour-predict.d.ts.map +1 -0
- package/dist/behaviour-request.d.ts +141 -0
- package/dist/behaviour-request.d.ts.map +1 -0
- package/dist/behaviour.d.ts +7 -7
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +10 -0
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lint/rules/evl011-symbolic-in-template.d.ts +3 -0
- package/dist/lint/rules/evl011-symbolic-in-template.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +1 -0
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +1 -1
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/predict-behaviour.d.ts +12 -5
- package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/attrref.test.ts +31 -0
- package/src/attrref.ts +33 -0
- package/src/behaviour-delta.test.ts +8 -8
- package/src/behaviour-engine.ts +478 -0
- package/src/behaviour-http.ts +1 -1
- package/src/behaviour-kinds.test.ts +235 -0
- package/src/behaviour-kinds.ts +324 -0
- package/src/behaviour-predict.ts +253 -0
- package/src/behaviour-request.ts +294 -0
- package/src/behaviour.ts +7 -7
- package/src/cli/handlers/scenario.test.ts +1 -1
- package/src/cli/handlers/scenario.ts +1 -1
- package/src/discovery/fold-composite.test.ts +70 -0
- package/src/discovery/fold-import.ts +48 -2
- package/src/fold/fold.test.ts +53 -0
- package/src/fold/fold.ts +43 -1
- package/src/index.ts +1 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/scenario-cost.test.ts +2 -2
- package/src/lint/rules/evl011-symbolic-in-template.test.ts +88 -0
- package/src/lint/rules/evl011-symbolic-in-template.ts +100 -0
- package/src/lint/rules/index.ts +3 -0
- package/src/op/activities/index.ts +1 -2
- package/src/op/activities/predict-behaviour.test.ts +20 -7
- package/src/op/activities/predict-behaviour.ts +32 -30
|
@@ -79,7 +79,7 @@ describe("validateBehaviourResult — a result held to the contract on arrival",
|
|
|
79
79
|
});
|
|
80
80
|
|
|
81
81
|
test("accepts a refusal with a legal cause, a reason and a remedy", () => {
|
|
82
|
-
const r = noBehaviourEngineRefusal("
|
|
82
|
+
const r = noBehaviourEngineRefusal("chant");
|
|
83
83
|
expect(validateBehaviourResult(r, ["db"])).toBe(r);
|
|
84
84
|
});
|
|
85
85
|
|
|
@@ -111,7 +111,7 @@ describe("validateBehaviourResult — a result held to the contract on arrival",
|
|
|
111
111
|
|
|
112
112
|
describe("behaviourDelta — a whole-run refusal on either side is no prediction", () => {
|
|
113
113
|
test("a refused base side yields no-prediction carrying that side's refusal, and no rows", () => {
|
|
114
|
-
const d = delta(noBehaviourEngineRefusal("
|
|
114
|
+
const d = delta(noBehaviourEngineRefusal("chant"), report({ db: figure() }));
|
|
115
115
|
expect(d.kind).toBe("no-prediction");
|
|
116
116
|
if (d.kind !== "no-prediction") return;
|
|
117
117
|
expect(d.base.refusal?.cause).toBe("no-engine");
|
|
@@ -120,20 +120,20 @@ describe("behaviourDelta — a whole-run refusal on either side is no prediction
|
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
test("a refused head side likewise, and both when both refuse", () => {
|
|
123
|
-
const down = unreachableBehaviourEngineRefusal("
|
|
123
|
+
const down = unreachableBehaviourEngineRefusal("chant", { value: "engine", source: "CHANT_BEHAVIOUR_ENGINE" }, "ECONNREFUSED");
|
|
124
124
|
const d = delta(report({ db: figure() }), down);
|
|
125
125
|
expect(d.kind).toBe("no-prediction");
|
|
126
|
-
const both = delta(noBehaviourEngineRefusal("
|
|
126
|
+
const both = delta(noBehaviourEngineRefusal("chant"), down);
|
|
127
127
|
if (both.kind !== "no-prediction") throw new Error("expected no-prediction");
|
|
128
128
|
expect(both.base.refusal?.cause).toBe("no-engine");
|
|
129
129
|
expect(both.head.refusal?.cause).toBe("engine-unreachable");
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
test("the rendered finding says no prediction, carries renderBehaviourRefusal's text and the remedy, and no figure", () => {
|
|
133
|
-
const d = delta(noBehaviourEngineRefusal("
|
|
133
|
+
const d = delta(noBehaviourEngineRefusal("chant"), report({ db: figure() }));
|
|
134
134
|
const body = renderBehaviourFinding(d, CTX);
|
|
135
135
|
expect(body).toContain("no prediction");
|
|
136
|
-
const refusal = noBehaviourEngineRefusal("
|
|
136
|
+
const refusal = noBehaviourEngineRefusal("chant").refusal;
|
|
137
137
|
expect(body).toContain(renderBehaviourRefusal(refusal, { color: false }));
|
|
138
138
|
expect(body).toContain(refusal.remedy);
|
|
139
139
|
expect(body).not.toContain("/hour");
|
|
@@ -145,7 +145,7 @@ describe("behaviourDelta — a declined entity is a row, not an absent finding a
|
|
|
145
145
|
const base = report({ db: figure(), orders: figure({ cost: predictedRate(0.004, "USD") }) });
|
|
146
146
|
const head = report(
|
|
147
147
|
{ db: figure() },
|
|
148
|
-
{ orders: { type: "AWS::SQS::Queue", reason: "unsupported-kind", detail: "AWS::SQS::Queue is declared unmapped by the
|
|
148
|
+
{ orders: { type: "AWS::SQS::Queue", reason: "unsupported-kind", detail: "AWS::SQS::Queue is declared unmapped by the aws coverage rows" } },
|
|
149
149
|
);
|
|
150
150
|
|
|
151
151
|
test("the declined side is a row of kind declined carrying the reason, with no delta number", () => {
|
|
@@ -285,7 +285,7 @@ describe("renderBehaviourFinding — every figure carries its provenance, and a
|
|
|
285
285
|
);
|
|
286
286
|
for (const body of [
|
|
287
287
|
renderBehaviourFinding(withEverything, CTX),
|
|
288
|
-
renderBehaviourFinding(delta(noBehaviourEngineRefusal("
|
|
288
|
+
renderBehaviourFinding(delta(noBehaviourEngineRefusal("chant"), report({ db: figure() })), CTX),
|
|
289
289
|
]) {
|
|
290
290
|
for (const word of FORBIDDEN) expect(body).not.toMatch(word);
|
|
291
291
|
}
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between core and whatever answers its request (#2357),
|
|
3
|
+
* on the contract's transport (#2373, decided in #2359).
|
|
4
|
+
*
|
|
5
|
+
* `packages/core/src/behaviour.ts` says what a prediction may mean, how an
|
|
6
|
+
* absent engine must refuse, and — since #2359 — how a request reaches an
|
|
7
|
+
* engine: a `BehaviourTransport` carries the rendered request and brings back
|
|
8
|
+
* the engine's text or a finished refusal. This file adds what is core's on
|
|
9
|
+
* top of that and nothing more: the parse of a `behaviour/v1` answer, a command
|
|
10
|
+
* transport for an address that is a program on `PATH`, and the chooser that
|
|
11
|
+
* turns an address into one transport or the other.
|
|
12
|
+
*
|
|
13
|
+
* {@link BehaviourEngine} is what `./predict-behaviour.ts` talks to. It is
|
|
14
|
+
* one level above the transport — request in, parsed answer or refusal out —
|
|
15
|
+
* so the fixture engine in `__fixtures__` can be one without pretending to be
|
|
16
|
+
* a wire, and so `predict-behaviour.ts` never sees a byte. {@link
|
|
17
|
+
* transportEngine} is the only bridge between the two levels.
|
|
18
|
+
*
|
|
19
|
+
* ## The two transports
|
|
20
|
+
*
|
|
21
|
+
* A **URL** is dialled by core's `httpBehaviourTransport`
|
|
22
|
+
* (`packages/core/src/behaviour-http.ts`): `POST`, a bearer token from
|
|
23
|
+
* `CHANT_BEHAVIOUR_TOKEN` → `BEHAVIOUR_TOKEN`,
|
|
24
|
+
* and the status mapping the contract fixes. Nothing about that is core's,
|
|
25
|
+
* which is why it does not live here.
|
|
26
|
+
*
|
|
27
|
+
* A **command on PATH** is {@link commandTransport}, below: request on stdin,
|
|
28
|
+
* answer on stdout, no shell — a shell would make the address a
|
|
29
|
+
* code-execution surface for whatever set the variable. The child gets
|
|
30
|
+
* `behaviourEngineChildEnvironment()` and nothing else, for the reason given
|
|
31
|
+
* on that function: the request-side screen cannot see an inherited
|
|
32
|
+
* `process.env`. An engine that answered and still refused says so on stderr,
|
|
33
|
+
* and the words it uses pick the cause.
|
|
34
|
+
*
|
|
35
|
+
* ## A malformed answer is unreachable, not a report with holes
|
|
36
|
+
*
|
|
37
|
+
* Taking the fields that parsed and reporting the rest unpredicted would turn
|
|
38
|
+
* an engine emitting garbage into an estate that looks partly free — the
|
|
39
|
+
* failure the refusal arm exists to prevent, one level down. So the parse
|
|
40
|
+
* refuses whole, with a detail naming the entity and the field.
|
|
41
|
+
*
|
|
42
|
+
* With one exception, and it is the same rule the command transport applies
|
|
43
|
+
* to stderr: an engine that took the request, answered `200`, and wrote
|
|
44
|
+
* `{"error": "out of credit"}` has refused for a reason a status never
|
|
45
|
+
* carried. {@link transportEngine} reads that body's words after the parse
|
|
46
|
+
* has failed and never before, so an answer that priced the estate and
|
|
47
|
+
* declined one node "rate limit reached for this region" stays the report it
|
|
48
|
+
* is. Both transports reach the same three causes; only the evidence differs.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { execFile, type ExecFileException } from "node:child_process";
|
|
52
|
+
import {
|
|
53
|
+
behaviourEngineChildEnvironment,
|
|
54
|
+
behaviourWireRefusal,
|
|
55
|
+
isBehaviourBasis,
|
|
56
|
+
isResilienceVerdict,
|
|
57
|
+
unreachableBehaviourEngineRefusal,
|
|
58
|
+
} from "./behaviour";
|
|
59
|
+
import type {
|
|
60
|
+
BehaviourBasis,
|
|
61
|
+
BehaviourEngineEndpoint,
|
|
62
|
+
BehaviourRefusalReport,
|
|
63
|
+
BehaviourTransport,
|
|
64
|
+
BehaviourWireCause,
|
|
65
|
+
ResilienceVerdict,
|
|
66
|
+
} from "./behaviour";
|
|
67
|
+
import { httpBehaviourTransport, isHttpBehaviourAddress } from "./behaviour-http";
|
|
68
|
+
import type { HttpBehaviourTransportDeps } from "./behaviour-http";
|
|
69
|
+
import type { EngineRequest } from "./behaviour-request";
|
|
70
|
+
import { renderEngineRequest } from "./behaviour-request";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The name a refusal is built under.
|
|
74
|
+
*
|
|
75
|
+
* It was the predicting lexicon's name while a lexicon predicted. Core
|
|
76
|
+
* predicts for the whole project since #2382, so there is no lexicon to scope
|
|
77
|
+
* the variable chain by: the chain is the chant-wide one, and this is the word
|
|
78
|
+
* a message uses for the thing that refused.
|
|
79
|
+
*/
|
|
80
|
+
export const BEHAVIOUR_SCOPE = "chant";
|
|
81
|
+
|
|
82
|
+
/** One entity's figures, as the engine states them. */
|
|
83
|
+
export interface EngineFigure {
|
|
84
|
+
perHour: number;
|
|
85
|
+
currency: string;
|
|
86
|
+
headroom: { cpu?: number; latency?: number };
|
|
87
|
+
errorRate: number;
|
|
88
|
+
resilience: { failure: string; verdict: ResilienceVerdict; note?: string };
|
|
89
|
+
rightSize?: { suggestion: string; reason?: string };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A run the engine answered. */
|
|
93
|
+
export interface EngineAnswer {
|
|
94
|
+
/** How the engine names itself, its version, and the tolerance it states. */
|
|
95
|
+
engine: string;
|
|
96
|
+
version: string;
|
|
97
|
+
tolerance: string;
|
|
98
|
+
basis: BehaviourBasis;
|
|
99
|
+
/** An estate total, when the engine states one of its own. Never chant's sum. */
|
|
100
|
+
total?: { perHour: number; currency: string };
|
|
101
|
+
/** Figures, keyed by the node name the request used. */
|
|
102
|
+
figures: Record<string, EngineFigure>;
|
|
103
|
+
/**
|
|
104
|
+
* Nodes the engine was sent and declined, keyed by name, with its reason.
|
|
105
|
+
* Separate from an absent key: a node in neither map is a defect, and
|
|
106
|
+
* `./predict-behaviour.ts` reports it rather than dropping it.
|
|
107
|
+
*/
|
|
108
|
+
declined?: Record<string, string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What an engine call comes back with: a parsed answer, or a refusal built
|
|
113
|
+
* by whoever saw the failure — the transport for a wire condition, this file
|
|
114
|
+
* for an answer that does not parse. `./predict-behaviour.ts` returns the
|
|
115
|
+
* refusal as it stands and never rebuilds one, which is how the contract's
|
|
116
|
+
* four causes stay four remedies rather than one lexicon's guess.
|
|
117
|
+
*/
|
|
118
|
+
export type EngineOutcome = { ok: true; answer: EngineAnswer } | { ok: false; refusal: BehaviourRefusalReport };
|
|
119
|
+
|
|
120
|
+
/** Whatever answers a request, one level above the wire. The fixture engine is one of these. */
|
|
121
|
+
export interface BehaviourEngine {
|
|
122
|
+
predict(request: EngineRequest): Promise<EngineOutcome>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve an address to an engine, or to `undefined` when nothing here
|
|
127
|
+
* speaks it. `env` is where a transport that authenticates reads its token
|
|
128
|
+
* from, and a chooser that has no use for it may ignore it. A caller that
|
|
129
|
+
* gets `undefined` refuses as `engine-unreachable` naming the address, which
|
|
130
|
+
* is the honest verdict for an address chant cannot dial.
|
|
131
|
+
*/
|
|
132
|
+
export type EngineConnect = (
|
|
133
|
+
endpoint: BehaviourEngineEndpoint,
|
|
134
|
+
env: Record<string, string | undefined>,
|
|
135
|
+
) => BehaviourEngine | undefined;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A {@link BehaviourEngine} over a contract transport: render the request
|
|
139
|
+
* canonically, send it, parse what came back. The one bridge between the
|
|
140
|
+
* transport level and the engine level, so the parse runs on every wire and
|
|
141
|
+
* no transport gets its own.
|
|
142
|
+
*/
|
|
143
|
+
export function transportEngine(
|
|
144
|
+
transport: BehaviourTransport,
|
|
145
|
+
endpoint: BehaviourEngineEndpoint,
|
|
146
|
+
): BehaviourEngine {
|
|
147
|
+
return {
|
|
148
|
+
async predict(request: EngineRequest): Promise<EngineOutcome> {
|
|
149
|
+
const sent = await transport.send(renderEngineRequest(request));
|
|
150
|
+
if (!sent.ok) return { ok: false, refusal: sent.refusal };
|
|
151
|
+
const parsed = parseEngineAnswer(sent.body);
|
|
152
|
+
if (!parsed.ok) {
|
|
153
|
+
// An engine that answers 200 with an error envelope is a real shape,
|
|
154
|
+
// and its own words are the only thing that says which refusal it is.
|
|
155
|
+
// Read after the parse and never before: an answer that priced the
|
|
156
|
+
// estate and declined one node "rate limit reached for this region"
|
|
157
|
+
// parses, and a vocabulary check running first would turn a report
|
|
158
|
+
// about the other nodes into an over-quota refusal about none.
|
|
159
|
+
const said = causeFromEngineWords(sent.body);
|
|
160
|
+
if (said) {
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
refusal: behaviourWireRefusal(BEHAVIOUR_SCOPE, endpoint, said, `the engine answered ${firstLine(sent.body)}`),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ok: false, refusal: unreachableBehaviourEngineRefusal(BEHAVIOUR_SCOPE, endpoint, parsed.detail) };
|
|
167
|
+
}
|
|
168
|
+
return { ok: true, answer: parsed.answer };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** How long a command engine is given before it is treated as unreachable. */
|
|
174
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
175
|
+
|
|
176
|
+
/** How much stdout is read before the answer is treated as malformed. */
|
|
177
|
+
const COMMAND_MAX_BUFFER = 8 * 1024 * 1024;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A `command on PATH` address, run as a subprocess: the contract's
|
|
181
|
+
* `BehaviourTransport` for the third kind of address it names.
|
|
182
|
+
*
|
|
183
|
+
* The address is split on whitespace into a program and its arguments, which
|
|
184
|
+
* is the shape `CHANT_BEHAVIOUR_ENGINE="my-engine --model tiny"` produces.
|
|
185
|
+
* No shell: a shell would make the address a code-execution surface for
|
|
186
|
+
* whatever set the variable, and every argument the address needs can be
|
|
187
|
+
* written without one.
|
|
188
|
+
*/
|
|
189
|
+
export function commandTransport(endpoint: BehaviourEngineEndpoint): BehaviourTransport {
|
|
190
|
+
const [program, ...args] = endpoint.value.trim().split(/\s+/);
|
|
191
|
+
return {
|
|
192
|
+
async send(body: string) {
|
|
193
|
+
const raw = await new Promise<{ stdout: string; error?: ExecFileException; stderr: string }>(
|
|
194
|
+
(resolve) => {
|
|
195
|
+
const child = execFile(
|
|
196
|
+
program,
|
|
197
|
+
args,
|
|
198
|
+
{
|
|
199
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
200
|
+
maxBuffer: COMMAND_MAX_BUFFER,
|
|
201
|
+
// Not `process.env`. The contract's rule, and its reason, are on
|
|
202
|
+
// `behaviourEngineChildEnvironment`; the test below pins it.
|
|
203
|
+
env: behaviourEngineChildEnvironment(),
|
|
204
|
+
},
|
|
205
|
+
(error, stdout, stderr) => {
|
|
206
|
+
resolve({ stdout: String(stdout), stderr: String(stderr), ...(error ? { error } : {}) });
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
child.stdin?.end(body);
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (raw.error) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
refusal: behaviourWireRefusal(
|
|
217
|
+
BEHAVIOUR_SCOPE,
|
|
218
|
+
endpoint,
|
|
219
|
+
causeFromEngineWords(raw.stderr) ?? "engine-unreachable",
|
|
220
|
+
firstLine(raw.stderr) || raw.error.message,
|
|
221
|
+
),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { ok: true, body: raw.stdout };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** {@link commandTransport}, bridged to the engine level. */
|
|
230
|
+
export function commandEngine(endpoint: BehaviourEngineEndpoint): BehaviourEngine {
|
|
231
|
+
return transportEngine(commandTransport(endpoint), endpoint);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* An engine that answered and still refused says so in words, and the three
|
|
236
|
+
* causes want three different actions (`behaviour.ts`'s remedy table). Matched
|
|
237
|
+
* on the words an engine would use rather than on an exit code or an HTTP
|
|
238
|
+
* status, because the contract fixes no exit codes, inventing some would bind
|
|
239
|
+
* every future engine to this file, and a status is only available on one of
|
|
240
|
+
* the two transports anyway.
|
|
241
|
+
*
|
|
242
|
+
* Both transports read it, on the text each has: a command's stderr, and an
|
|
243
|
+
* HTTP body that failed to parse as an answer. `./behaviour-http.ts` has
|
|
244
|
+
* already classified every status that carries one, so this runs on the case
|
|
245
|
+
* a status does not cover — the engine that took the request, answered 200,
|
|
246
|
+
* and put its refusal in the envelope.
|
|
247
|
+
*/
|
|
248
|
+
function causeFromEngineWords(said: string): BehaviourWireCause | undefined {
|
|
249
|
+
const text = said.toLowerCase();
|
|
250
|
+
if (/\b(out of credit|insufficient (funds|balance)|no balance|payment required)\b/.test(text)) {
|
|
251
|
+
return "engine-out-of-credit";
|
|
252
|
+
}
|
|
253
|
+
if (/\b(over quota|quota exceeded|rate limit|too many requests|throttl)/.test(text)) {
|
|
254
|
+
return "engine-over-quota";
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function firstLine(text: string): string {
|
|
260
|
+
return text.split("\n").find((line) => line.trim().length > 0)?.trim() ?? "";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Everything wrong with one figure, as readable field paths. Empty means the
|
|
265
|
+
* figure is usable.
|
|
266
|
+
*
|
|
267
|
+
* Pure and exported so the shapes an engine can get wrong are testable without
|
|
268
|
+
* a subprocess. Every check here mirrors one `validateBehaviourBlock` applies
|
|
269
|
+
* downstream (`packages/core/src/behaviour.ts`) — the difference is *when*:
|
|
270
|
+
* that one throws, and a throw is the whole-lexicon failure `lexicon.ts`
|
|
271
|
+
* reserves for a live credential. A third party emitting one bad number is not
|
|
272
|
+
* that, and reporting it as that is how an operator goes looking for a leak.
|
|
273
|
+
*/
|
|
274
|
+
export function figureProblems(name: string, figure: unknown): string[] {
|
|
275
|
+
const at = (field: string) => `figures.${name}.${field}`;
|
|
276
|
+
if (typeof figure !== "object" || figure === null || Array.isArray(figure)) {
|
|
277
|
+
return [`${at("")} is ${Array.isArray(figure) ? "an array" : typeof figure}, not an object`];
|
|
278
|
+
}
|
|
279
|
+
const f = figure as Record<string, unknown>;
|
|
280
|
+
const out: string[] = [];
|
|
281
|
+
|
|
282
|
+
const fraction = (field: string, value: unknown) => {
|
|
283
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
284
|
+
out.push(`${at(field)} is not a number in 0..1`);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
if (typeof f.perHour !== "number" || !Number.isFinite(f.perHour) || f.perHour < 0) {
|
|
289
|
+
out.push(`${at("perHour")} is not a non-negative finite number`);
|
|
290
|
+
}
|
|
291
|
+
if (typeof f.currency !== "string" || f.currency.trim().length === 0) {
|
|
292
|
+
out.push(`${at("currency")} is empty`);
|
|
293
|
+
}
|
|
294
|
+
fraction("errorRate", f.errorRate);
|
|
295
|
+
|
|
296
|
+
const headroom = f.headroom;
|
|
297
|
+
if (typeof headroom !== "object" || headroom === null || Array.isArray(headroom)) {
|
|
298
|
+
out.push(`${at("headroom")} is missing`);
|
|
299
|
+
} else {
|
|
300
|
+
const h = headroom as Record<string, unknown>;
|
|
301
|
+
// At least one axis, and an axis the engine did not model must be absent
|
|
302
|
+
// rather than zero — zero headroom means saturated, the opposite claim.
|
|
303
|
+
if (h.cpu === undefined && h.latency === undefined) {
|
|
304
|
+
out.push(`${at("headroom")} carries neither cpu nor latency`);
|
|
305
|
+
}
|
|
306
|
+
for (const axis of ["cpu", "latency"] as const) {
|
|
307
|
+
if (h[axis] !== undefined) fraction(`headroom.${axis}`, h[axis]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const resilience = f.resilience;
|
|
312
|
+
if (typeof resilience !== "object" || resilience === null || Array.isArray(resilience)) {
|
|
313
|
+
out.push(`${at("resilience")} is missing`);
|
|
314
|
+
} else {
|
|
315
|
+
const r = resilience as Record<string, unknown>;
|
|
316
|
+
if (typeof r.failure !== "string" || r.failure.trim().length === 0) {
|
|
317
|
+
out.push(`${at("resilience.failure")} names no failure`);
|
|
318
|
+
}
|
|
319
|
+
if (!isResilienceVerdict(r.verdict)) {
|
|
320
|
+
out.push(`${at("resilience.verdict")} is not survives/degrades/fails`);
|
|
321
|
+
}
|
|
322
|
+
if (r.note !== undefined && typeof r.note !== "string") {
|
|
323
|
+
out.push(`${at("resilience.note")} is not a string`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const rightSize = f.rightSize;
|
|
328
|
+
if (rightSize !== undefined) {
|
|
329
|
+
if (typeof rightSize !== "object" || rightSize === null || Array.isArray(rightSize)) {
|
|
330
|
+
out.push(`${at("rightSize")} is not an object`);
|
|
331
|
+
} else if (typeof (rightSize as Record<string, unknown>).suggestion !== "string") {
|
|
332
|
+
out.push(`${at("rightSize.suggestion")} is missing`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What the parse of an engine's text comes to: the answer, or a detail
|
|
341
|
+
* naming what was wrong with it. Not a refusal yet — {@link transportEngine}
|
|
342
|
+
* builds that, with the endpoint the parse does not need to know.
|
|
343
|
+
*/
|
|
344
|
+
export type ParsedEngineAnswer = { ok: true; answer: EngineAnswer } | { ok: false; detail: string };
|
|
345
|
+
|
|
346
|
+
/** One malformed verdict, with the detail the refusal will scrub and print. */
|
|
347
|
+
function malformed(detail: string): ParsedEngineAnswer {
|
|
348
|
+
return { ok: false, detail };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse an engine's text into an {@link EngineAnswer}.
|
|
353
|
+
*
|
|
354
|
+
* A malformed answer is `engine-unreachable` and not a report full of holes.
|
|
355
|
+
* The alternative — taking the fields that parsed and reporting the rest as
|
|
356
|
+
* unpredicted — would turn an engine emitting garbage into an estate that
|
|
357
|
+
* looks partially free, which is the failure the refusal arm exists to
|
|
358
|
+
* prevent, one level down.
|
|
359
|
+
*
|
|
360
|
+
* **Every figure is validated here, not only the envelope.** The first version
|
|
361
|
+
* of this function checked `engine`/`version`/`tolerance`/`basis` and that
|
|
362
|
+
* `figures` was an object, and then handed each figure's contents straight to
|
|
363
|
+
* `block()` in `./predict-behaviour.ts`, which dereferenced
|
|
364
|
+
* `figure.resilience.failure`. Seven malformed shapes were executed against
|
|
365
|
+
* it and all seven threw: three as bare `TypeError`s with no chant message,
|
|
366
|
+
* four through `validateBehaviourBlock` after the fact. A throw is the
|
|
367
|
+
* whole-lexicon failure `lexicon.ts` reserves for a live credential in the
|
|
368
|
+
* request, so a third-party engine emitting one bad number was indistinguishable
|
|
369
|
+
* from a leak — and the doc above claimed the opposite was happening.
|
|
370
|
+
*
|
|
371
|
+
* The detail names the entity and the field, because "the engine sent
|
|
372
|
+
* something wrong" is not a thing anybody can act on and "figures.web.headroom
|
|
373
|
+
* carries neither cpu nor latency" is. It flows into
|
|
374
|
+
* `unreachableBehaviourEngineRefusal`, which runs it through
|
|
375
|
+
* `scrubEngineDetail` and bounds it.
|
|
376
|
+
*/
|
|
377
|
+
export function parseEngineAnswer(text: string): ParsedEngineAnswer {
|
|
378
|
+
let parsed: unknown;
|
|
379
|
+
try {
|
|
380
|
+
parsed = JSON.parse(text);
|
|
381
|
+
} catch {
|
|
382
|
+
return malformed(`the engine wrote ${text.length} byte(s) that are not JSON`);
|
|
383
|
+
}
|
|
384
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
385
|
+
return malformed("the engine's answer is not an object");
|
|
386
|
+
}
|
|
387
|
+
const answer = parsed as Partial<EngineAnswer>;
|
|
388
|
+
|
|
389
|
+
const missing = (["engine", "version", "tolerance"] as const).filter(
|
|
390
|
+
(key) => typeof answer[key] !== "string" || (answer[key] as string).trim().length === 0,
|
|
391
|
+
);
|
|
392
|
+
if (missing.length > 0) {
|
|
393
|
+
return malformed(
|
|
394
|
+
`the engine's answer states no ${missing.join(", ")}. Every figure carries provenance, so an ` +
|
|
395
|
+
"answer that cannot say who produced it is not a usable answer",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
// A closed enum downstream, so a free-form basis is caught here rather than
|
|
399
|
+
// by a throw from `validateBehaviourBlock` after the report is half built.
|
|
400
|
+
if (!isBehaviourBasis(answer.basis)) {
|
|
401
|
+
return malformed(
|
|
402
|
+
`the engine's answer states a basis of ${JSON.stringify(answer.basis)}, which is not ` +
|
|
403
|
+
"modeled or validated",
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (typeof answer.figures !== "object" || answer.figures === null || Array.isArray(answer.figures)) {
|
|
408
|
+
// `Array.isArray` explicitly: `typeof [] === "object"`, so `figures: []`
|
|
409
|
+
// parsed clean and produced a report in which every node had been lost.
|
|
410
|
+
return malformed("the engine's answer carries no figures map");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (answer.total !== undefined) {
|
|
414
|
+
const total = answer.total as Record<string, unknown>;
|
|
415
|
+
if (
|
|
416
|
+
typeof total !== "object" ||
|
|
417
|
+
total === null ||
|
|
418
|
+
typeof total.perHour !== "number" ||
|
|
419
|
+
!Number.isFinite(total.perHour) ||
|
|
420
|
+
total.perHour < 0 ||
|
|
421
|
+
typeof total.currency !== "string" ||
|
|
422
|
+
total.currency.trim().length === 0
|
|
423
|
+
) {
|
|
424
|
+
return malformed("the engine's answer states a total that is not a non-negative rate in a named currency");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (answer.declined !== undefined) {
|
|
429
|
+
const declined = answer.declined as unknown;
|
|
430
|
+
if (typeof declined !== "object" || declined === null || Array.isArray(declined)) {
|
|
431
|
+
return malformed("the engine's answer carries a declined list that is not a map");
|
|
432
|
+
}
|
|
433
|
+
for (const [name, reason] of Object.entries(declined as Record<string, unknown>)) {
|
|
434
|
+
if (typeof reason !== "string") {
|
|
435
|
+
return malformed(`the engine declined ${name} with a reason that is not a string`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const problems: string[] = [];
|
|
441
|
+
for (const [name, figure] of Object.entries(answer.figures as Record<string, unknown>)) {
|
|
442
|
+
problems.push(...figureProblems(name, figure));
|
|
443
|
+
if (problems.length >= 5) break;
|
|
444
|
+
}
|
|
445
|
+
if (problems.length > 0) {
|
|
446
|
+
return malformed(
|
|
447
|
+
`the engine's answer is malformed: ${problems.slice(0, 5).join("; ")}` +
|
|
448
|
+
(problems.length >= 5 ? " (and possibly more)" : ""),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { ok: true, answer: answer as EngineAnswer };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The transport chooser, with what the HTTP transport needs injectable.
|
|
457
|
+
*
|
|
458
|
+
* A `http://` or `https://` address gets core's `httpBehaviourTransport`,
|
|
459
|
+
* with its token read from `env` — the same `env` the address was read from,
|
|
460
|
+
* so a test drives both chains from one object. A bare address gets
|
|
461
|
+
* {@link commandTransport}. Any other scheme (`grpc://`, `unix://`) gets
|
|
462
|
+
* `undefined`, and the caller refuses naming the address: no transport here
|
|
463
|
+
* speaks it, and saying so beats a guess.
|
|
464
|
+
*/
|
|
465
|
+
export function connectWith(deps: HttpBehaviourTransportDeps = {}): EngineConnect {
|
|
466
|
+
return (endpoint, env) => {
|
|
467
|
+
const address = endpoint.value.trim();
|
|
468
|
+
if (address.length === 0) return undefined;
|
|
469
|
+
if (isHttpBehaviourAddress(address)) {
|
|
470
|
+
return transportEngine(httpBehaviourTransport(BEHAVIOUR_SCOPE, endpoint, env, deps), endpoint);
|
|
471
|
+
}
|
|
472
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(address)) return undefined;
|
|
473
|
+
return commandEngine(endpoint);
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The chooser the shipped plugin uses: the process's own `fetch`, the default deadline. */
|
|
478
|
+
export const defaultConnect: EngineConnect = connectWith();
|
package/src/behaviour-http.ts
CHANGED
|
@@ -82,7 +82,7 @@ export interface HttpBehaviourTransportDeps {
|
|
|
82
82
|
timeoutMs?: number;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/** The default deadline: the same one
|
|
85
|
+
/** The default deadline: the same one the command transport gives a child. */
|
|
86
86
|
export const HTTP_BEHAVIOUR_TIMEOUT_MS = 30_000;
|
|
87
87
|
|
|
88
88
|
/** How much of an error body a `detail` repeats. `scrubEngineDetail` bounds it again downstream. */
|