@loomwork/ui-hydration 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +398 -2
- package/dist/cache.d.ts +71 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +157 -0
- package/dist/cache.js.map +1 -0
- package/dist/chatSource.d.ts +105 -0
- package/dist/chatSource.d.ts.map +1 -0
- package/dist/chatSource.js +259 -0
- package/dist/chatSource.js.map +1 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +36 -0
- package/dist/client.js.map +1 -0
- package/dist/contentRefs.d.ts +78 -0
- package/dist/contentRefs.d.ts.map +1 -0
- package/dist/contentRefs.js +129 -0
- package/dist/contentRefs.js.map +1 -0
- package/dist/controller.d.ts +228 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +454 -0
- package/dist/controller.js.map +1 -0
- package/dist/dataProvider.d.ts +53 -0
- package/dist/dataProvider.d.ts.map +1 -0
- package/dist/dataProvider.js +77 -0
- package/dist/dataProvider.js.map +1 -0
- package/dist/esm/cache.js +151 -0
- package/dist/esm/cache.js.map +1 -0
- package/dist/esm/chatSource.js +254 -0
- package/dist/esm/chatSource.js.map +1 -0
- package/dist/esm/client.js +15 -0
- package/dist/esm/client.js.map +1 -0
- package/dist/esm/contentRefs.js +119 -0
- package/dist/esm/contentRefs.js.map +1 -0
- package/dist/esm/controller.js +449 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/dataProvider.js +72 -0
- package/dist/esm/dataProvider.js.map +1 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/invoker.js +2 -0
- package/dist/esm/invoker.js.map +1 -0
- package/dist/esm/invokers.js +310 -0
- package/dist/esm/invokers.js.map +1 -0
- package/dist/esm/loomEffectProvider.js +96 -0
- package/dist/esm/loomEffectProvider.js.map +1 -0
- package/dist/esm/params.js +207 -0
- package/dist/esm/params.js.map +1 -0
- package/dist/esm/path.js +24 -0
- package/dist/esm/path.js.map +1 -0
- package/dist/esm/plan.js +190 -0
- package/dist/esm/plan.js.map +1 -0
- package/dist/esm/projection.js +230 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/provider.js +416 -0
- package/dist/esm/provider.js.map +1 -0
- package/dist/esm/repair.js +53 -0
- package/dist/esm/repair.js.map +1 -0
- package/dist/esm/reshape.js +156 -0
- package/dist/esm/reshape.js.map +1 -0
- package/dist/esm/restricted.js +54 -0
- package/dist/esm/restricted.js.map +1 -0
- package/dist/esm/scheduler.js +247 -0
- package/dist/esm/scheduler.js.map +1 -0
- package/dist/esm/select.js +18 -0
- package/dist/esm/select.js.map +1 -0
- package/dist/esm/shape.js +34 -0
- package/dist/esm/shape.js.map +1 -0
- package/dist/esm/split/client.js +309 -0
- package/dist/esm/split/client.js.map +1 -0
- package/dist/esm/split/index.js +3 -0
- package/dist/esm/split/index.js.map +1 -0
- package/dist/esm/split/server.js +488 -0
- package/dist/esm/split/server.js.map +1 -0
- package/dist/esm/split/shared.js +13 -0
- package/dist/esm/split/shared.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/invoker.d.ts +10 -0
- package/dist/invoker.d.ts.map +1 -0
- package/dist/invoker.js +3 -0
- package/dist/invoker.js.map +1 -0
- package/dist/invokers.d.ts +36 -0
- package/dist/invokers.d.ts.map +1 -0
- package/dist/invokers.js +315 -0
- package/dist/invokers.js.map +1 -0
- package/dist/loomEffectProvider.d.ts +67 -0
- package/dist/loomEffectProvider.d.ts.map +1 -0
- package/dist/loomEffectProvider.js +101 -0
- package/dist/loomEffectProvider.js.map +1 -0
- package/dist/params.d.ts +103 -0
- package/dist/params.d.ts.map +1 -0
- package/dist/params.js +221 -0
- package/dist/params.js.map +1 -0
- package/dist/path.d.ts +12 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +28 -0
- package/dist/path.js.map +1 -0
- package/dist/plan.d.ts +56 -0
- package/dist/plan.d.ts.map +1 -0
- package/dist/plan.js +199 -0
- package/dist/plan.js.map +1 -0
- package/dist/projection.d.ts +122 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +238 -0
- package/dist/projection.js.map +1 -0
- package/dist/provider.d.ts +152 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +421 -0
- package/dist/provider.js.map +1 -0
- package/dist/repair.d.ts +18 -0
- package/dist/repair.d.ts.map +1 -0
- package/dist/repair.js +57 -0
- package/dist/repair.js.map +1 -0
- package/dist/reshape.d.ts +32 -0
- package/dist/reshape.d.ts.map +1 -0
- package/dist/reshape.js +164 -0
- package/dist/reshape.js.map +1 -0
- package/dist/restricted.d.ts +21 -0
- package/dist/restricted.d.ts.map +1 -0
- package/dist/restricted.js +61 -0
- package/dist/restricted.js.map +1 -0
- package/dist/scheduler.d.ts +82 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +254 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/select.d.ts +34 -0
- package/dist/select.d.ts.map +1 -0
- package/dist/select.js +21 -0
- package/dist/select.js.map +1 -0
- package/dist/shape.d.ts +3 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +37 -0
- package/dist/shape.js.map +1 -0
- package/dist/split/client.d.ts +132 -0
- package/dist/split/client.d.ts.map +1 -0
- package/dist/split/client.js +313 -0
- package/dist/split/client.js.map +1 -0
- package/dist/split/index.d.ts +5 -0
- package/dist/split/index.d.ts.map +1 -0
- package/dist/split/index.js +14 -0
- package/dist/split/index.js.map +1 -0
- package/dist/split/server.d.ts +160 -0
- package/dist/split/server.d.ts.map +1 -0
- package/dist/split/server.js +496 -0
- package/dist/split/server.js.map +1 -0
- package/dist/split/shared.d.ts +6 -0
- package/dist/split/shared.d.ts.map +1 -0
- package/dist/split/shared.js +19 -0
- package/dist/split/shared.js.map +1 -0
- package/package.json +77 -3
- package/src/cache.ts +211 -0
- package/src/chatSource.ts +374 -0
- package/src/client.ts +46 -0
- package/src/contentRefs.ts +195 -0
- package/src/controller.ts +708 -0
- package/src/dataProvider.ts +141 -0
- package/src/index.ts +86 -0
- package/src/invoker.ts +14 -0
- package/src/invokers.ts +348 -0
- package/src/loomEffectProvider.ts +136 -0
- package/src/params.ts +314 -0
- package/src/path.ts +26 -0
- package/src/plan.ts +197 -0
- package/src/projection.ts +297 -0
- package/src/provider.ts +657 -0
- package/src/repair.ts +70 -0
- package/src/reshape.ts +193 -0
- package/src/restricted.ts +69 -0
- package/src/scheduler.ts +284 -0
- package/src/select.ts +59 -0
- package/src/shape.ts +39 -0
- package/src/split/client.ts +449 -0
- package/src/split/index.ts +18 -0
- package/src/split/server.ts +746 -0
- package/src/split/shared.ts +18 -0
package/src/provider.ts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
import { type TaintedValue, tainted } from "@loomwork/classify";
|
|
2
|
+
import { TypedFailure } from "@loomwork/core";
|
|
3
|
+
import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
|
|
4
|
+
import { type Shape, shapeOf } from "@loomwork/plugin-sandbox";
|
|
5
|
+
import {
|
|
6
|
+
type ContentRefTable,
|
|
7
|
+
type ContractRegistry,
|
|
8
|
+
type Finding,
|
|
9
|
+
type MCPToolNode,
|
|
10
|
+
type ProviderSpec,
|
|
11
|
+
checkKindValue,
|
|
12
|
+
hasKindContract,
|
|
13
|
+
stepKind,
|
|
14
|
+
} from "@loomwork/ui-core";
|
|
15
|
+
import {
|
|
16
|
+
type ContentRefParamGate,
|
|
17
|
+
checkContentRefs,
|
|
18
|
+
contentRefEnv,
|
|
19
|
+
finishContentRefArgs,
|
|
20
|
+
} from "./contentRefs";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_DATA_PROVIDERS,
|
|
23
|
+
type DataProvider,
|
|
24
|
+
type DataProviderRegistry,
|
|
25
|
+
} from "./dataProvider";
|
|
26
|
+
import type { TransientInvoker } from "./invoker";
|
|
27
|
+
import {
|
|
28
|
+
type ArgumentHook,
|
|
29
|
+
type LoadParams,
|
|
30
|
+
pickParams,
|
|
31
|
+
prepareArguments,
|
|
32
|
+
providerParams,
|
|
33
|
+
} from "./params";
|
|
34
|
+
import { terminalSteps, topoWaves } from "./plan";
|
|
35
|
+
import {
|
|
36
|
+
type ReshapeFailureKind,
|
|
37
|
+
type ReshaperRegistry,
|
|
38
|
+
outputShapeHashOf,
|
|
39
|
+
reshape,
|
|
40
|
+
safeStringify,
|
|
41
|
+
} from "./reshape";
|
|
42
|
+
import type { HandleLoadResult } from "./scheduler";
|
|
43
|
+
|
|
44
|
+
// ── Executing one provider ────────────────────────────────────────────────────
|
|
45
|
+
// The transient interpreter (ADRs 0060–0066): walk the graph in waves, invoke `mcp.invoke` per
|
|
46
|
+
// step with the pinned input-schema hash, reshape the terminal output, check it. Zero events on
|
|
47
|
+
// this path. Each step's arguments are prepared in a fixed order (params.ts): per-load params on
|
|
48
|
+
// the arguments the step declares overridable, relative-time tokens against ONE instant per
|
|
49
|
+
// call, `{{steps.…}}` templates, then the host's argument hook. The output is checked by its
|
|
50
|
+
// shape pin (`outputCheck` absent or "shape") or by the value contracts of the kinds it feeds
|
|
51
|
+
// ("contract"). A pin that drifts is REPORTED. By default the step fails and the handle goes
|
|
52
|
+
// stale; under `inputDrift: "report-and-proceed"` (a read-only viewer, which cannot persist a
|
|
53
|
+
// repair) the step is retried ONCE with the observed hash and the value is published — the drift
|
|
54
|
+
// is still returned, so the app can repair it. The durable repair is always the app's, and
|
|
55
|
+
// `remapOperation` (repair.ts) builds its operation. Output drift is never proceeded past.
|
|
56
|
+
|
|
57
|
+
export interface DriftRecord {
|
|
58
|
+
/**
|
|
59
|
+
* `input`: a step's input-schema pin moved — a pin move `remapOperation` can repair. `output`:
|
|
60
|
+
* the reshaping no longer works for the tool's output — the reshaped value fails its shape pin
|
|
61
|
+
* or value contract, OR the reshaping itself failed on it (`reshapeFailure`) — which only a
|
|
62
|
+
* re-authored binding (a planner) can repair.
|
|
63
|
+
*/
|
|
64
|
+
readonly kind: "input" | "output";
|
|
65
|
+
readonly handle: string;
|
|
66
|
+
/**
|
|
67
|
+
* The step that drifted. Input drift: the step whose schema pin drifted (always present).
|
|
68
|
+
* Output drift: the terminal step whose output was reshaped, set only when the graph has
|
|
69
|
+
* exactly one terminal — with several terminals the raw sample is a record keyed by stepId, not
|
|
70
|
+
* one step's output, so no single step identity applies.
|
|
71
|
+
*/
|
|
72
|
+
readonly stepId?: string;
|
|
73
|
+
readonly toolName?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Input drift only, and ONLY for a step whose kind is not MCP: that kind (`step.kind`), so a
|
|
76
|
+
* repairer knows `toolName` is a target of that kind, not an MCP tool. Absent for MCP — a record
|
|
77
|
+
* for an MCP step is exactly what it was before kinds existed.
|
|
78
|
+
*/
|
|
79
|
+
readonly providerKind?: string;
|
|
80
|
+
readonly expected: string;
|
|
81
|
+
readonly observed: string;
|
|
82
|
+
/**
|
|
83
|
+
* Output drift under `outputCheck: "contract"` only: the kind whose value contract the reshaped
|
|
84
|
+
* value failed. `expected` is then `contract:<kind>` and `observed` the value's shape hash.
|
|
85
|
+
*/
|
|
86
|
+
readonly contractKind?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Output drift only, set when the RESHAPING ITSELF failed on the tool's output — and only when
|
|
89
|
+
* the output demonstrably caused it (`reshapeFailureKind`): the failure's finding code
|
|
90
|
+
* (`reshape-<channel>` / `reshape-failed`). `expected` is then `reshape` and `observed` that
|
|
91
|
+
* same code, and there is no reshaped value, so `observedShape` is absent — the raw output
|
|
92
|
+
* (`observedRawShape`, `observedSample`) is what a repair is authored against. Infrastructure
|
|
93
|
+
* and transient failures (a `wallClock` limit, an untyped throw, a denial, a missing script or
|
|
94
|
+
* reshaper, an abort) record no drift. The handle's status stays `error` (not `stale`) and the
|
|
95
|
+
* public finding is unchanged; the drift is reported for repair (`onDrift`).
|
|
96
|
+
*/
|
|
97
|
+
readonly reshapeFailure?: string;
|
|
98
|
+
/** Beside `reshapeFailure`: WHY, typed — so a consumer can exclude a kind without parsing findings. */
|
|
99
|
+
readonly reshapeFailureKind?: ReshapeFailureKind;
|
|
100
|
+
/**
|
|
101
|
+
* Output drift only: the structural shape loom's `shapeOf` saw of the RESHAPED value — the one
|
|
102
|
+
* that failed the check, so it explains the mismatch. Absent when the value could not be walked,
|
|
103
|
+
* and on a `reshapeFailure` (there is no reshaped value).
|
|
104
|
+
*/
|
|
105
|
+
readonly observedShape?: Shape;
|
|
106
|
+
/**
|
|
107
|
+
* Output drift only: the structural shape of the RAW terminal output the reshaping was applied
|
|
108
|
+
* to. A planner re-authors a `reshaping.scriptRef`, which is a path into the RAW output — so
|
|
109
|
+
* without this a record carrying no sample gives it nothing to author a path against.
|
|
110
|
+
* Absent when the raw value could not be walked.
|
|
111
|
+
*/
|
|
112
|
+
readonly observedRawShape?: Shape;
|
|
113
|
+
/**
|
|
114
|
+
* Output drift only, and ONLY when `ExecuteOptions.captureSample` is set: the RAW terminal
|
|
115
|
+
* output (the single terminal step's output, or the stepId-keyed record when there are several)
|
|
116
|
+
* — NOT the reshaped value, because the planner consumes a sample as a capability's
|
|
117
|
+
* `sampleOutput`, which is raw by contract and is what a `path` is authored against. ALWAYS
|
|
118
|
+
* wrapped as untrusted content (ADR 0085): it is tool-authored and will be shown to a model.
|
|
119
|
+
*/
|
|
120
|
+
readonly observedSample?: TaintedValue<unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ProviderOutcome =
|
|
124
|
+
/** `drift` only when an input drift was proceeded past (`inputDrift: "report-and-proceed"`). */
|
|
125
|
+
| { ok: true; value: unknown; findings: Finding[]; drift?: DriftRecord[] }
|
|
126
|
+
| { ok: false; finding: Finding; drift: DriftRecord[]; findings: Finding[] };
|
|
127
|
+
|
|
128
|
+
/** What a load does on an input-schema drift: go stale (`report`), or record it and retry once with the observed pin. */
|
|
129
|
+
export type InputDriftMode = "report" | "report-and-proceed";
|
|
130
|
+
|
|
131
|
+
export interface ExecuteOptions {
|
|
132
|
+
readonly invoker: TransientInvoker;
|
|
133
|
+
readonly signal?: AbortSignal;
|
|
134
|
+
readonly principal?: TransientInvokeOptions["principal"];
|
|
135
|
+
/** Parallel steps per wave. Default 4. */
|
|
136
|
+
readonly concurrency?: number;
|
|
137
|
+
/** Attach the RAW terminal output to an output-drift record as a tainted sample. Default false. */
|
|
138
|
+
readonly captureSample?: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* The kinds the handle's bound nodes resolve to on the surface being hydrated (core's
|
|
141
|
+
* `handleKinds`). Read only when the spec's `outputCheck` is `"contract"` — which REFUSES to run
|
|
142
|
+
* without them: a contract check over no kinds would pass every value.
|
|
143
|
+
*/
|
|
144
|
+
readonly kinds?: readonly string[];
|
|
145
|
+
/**
|
|
146
|
+
* The host's value contracts for kinds outside the framework's (`SemanticCatalog.valueContracts`).
|
|
147
|
+
* A `"contract"` provider feeding a kind with neither contract is REFUSED (`output-contract-missing`).
|
|
148
|
+
*/
|
|
149
|
+
readonly contracts?: ContractRegistry;
|
|
150
|
+
/**
|
|
151
|
+
* Per-load params: applied only to arguments a step declares `overridable`, never to a
|
|
152
|
+
* `{{steps.…}}` template. A param no step declares is ignored with a `param-not-overridable` warn.
|
|
153
|
+
*/
|
|
154
|
+
readonly params?: LoadParams;
|
|
155
|
+
/** The instant relative-time tokens resolve against (epoch ms). Default: `Date.now()`, read once. */
|
|
156
|
+
readonly now?: number;
|
|
157
|
+
/** The host's last word on each step's arguments (after params, tokens, templates). */
|
|
158
|
+
readonly argumentHook?: ArgumentHook;
|
|
159
|
+
/**
|
|
160
|
+
* The marked content-reference argument paths per tool (`toolKey`; core `contentRefPaths`, the
|
|
161
|
+
* planner's `contentRefArgsFrom`). A step whose tool is listed is refused (`media-arg-literal`, the
|
|
162
|
+
* call not made) when a marked argument holds a literal. Absent = nothing is marked.
|
|
163
|
+
*/
|
|
164
|
+
readonly contentRefArgs?: ContentRefTable;
|
|
165
|
+
/** Who the load runs for, as the gate's `principalId` (the controller's `principal.id`, serve's `principalId`). */
|
|
166
|
+
readonly principalId?: string;
|
|
167
|
+
/** The host's gate on a param that fills a marked argument; absent = every such param is refused. */
|
|
168
|
+
readonly acceptContentRefParam?: ContentRefParamGate;
|
|
169
|
+
/** On an input-schema drift. Default `"report"`. */
|
|
170
|
+
readonly inputDrift?: InputDriftMode;
|
|
171
|
+
/** Where `reshaping.engine: "Named"` looks its reshaper up. */
|
|
172
|
+
readonly reshapers?: ReshaperRegistry;
|
|
173
|
+
/**
|
|
174
|
+
* The data-provider kinds this host composed (`defineDataProviders`). Default: MCP only, which is
|
|
175
|
+
* every step of an experience written before kinds existed. A step naming a kind not in here
|
|
176
|
+
* fails the whole provider as `provider-kind-unregistered`, before any step runs.
|
|
177
|
+
*/
|
|
178
|
+
readonly providers?: DataProviderRegistry;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function mapLimit<T, R>(
|
|
182
|
+
items: readonly T[],
|
|
183
|
+
limit: number,
|
|
184
|
+
fn: (item: T) => Promise<R>
|
|
185
|
+
): Promise<R[]> {
|
|
186
|
+
const results = new Array<R>(items.length);
|
|
187
|
+
let cursor = 0;
|
|
188
|
+
const worker = async (): Promise<void> => {
|
|
189
|
+
for (;;) {
|
|
190
|
+
const i = cursor++;
|
|
191
|
+
if (i >= items.length) return;
|
|
192
|
+
results[i] = await fn(items[i] as T);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const err = (code: string, message: string): Finding => ({ code, severity: "error", message });
|
|
200
|
+
|
|
201
|
+
/** A step's invocation failed for a reason other than an input-schema drift. */
|
|
202
|
+
function stepFailure(node: MCPToolNode, e: unknown, signal: AbortSignal | undefined): Finding {
|
|
203
|
+
if (e instanceof TypedFailure)
|
|
204
|
+
return err(
|
|
205
|
+
`tool-${e.channel}`,
|
|
206
|
+
`step "${node.stepId}" (${node.toolName}) failed on channel ${e.channel}: ${safeStringify(e.payload)}`
|
|
207
|
+
);
|
|
208
|
+
if (signal?.aborted) return err("aborted", `step "${node.stepId}" aborted`);
|
|
209
|
+
return err(
|
|
210
|
+
"tool-failed",
|
|
211
|
+
`step "${node.stepId}" (${node.toolName}) failed: ${e instanceof Error ? e.message : String(e)}`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The shapes an output-drift record carries. Neither value has necessarily been proved walkable
|
|
217
|
+
* (the raw output never went through `shapeHash`): one too deep or too wide simply leaves its
|
|
218
|
+
* field absent — a drift REPORT must never become a throw.
|
|
219
|
+
*/
|
|
220
|
+
function observedShapes(
|
|
221
|
+
value: unknown,
|
|
222
|
+
raw: unknown
|
|
223
|
+
): { observedShape?: Shape; observedRawShape?: Shape } {
|
|
224
|
+
const out: { observedShape?: Shape; observedRawShape?: Shape } = {};
|
|
225
|
+
try {
|
|
226
|
+
out.observedShape = shapeOf(value);
|
|
227
|
+
} catch {
|
|
228
|
+
// unwalkable: leave absent
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
out.observedRawShape = shapeOf(raw);
|
|
232
|
+
} catch {
|
|
233
|
+
// unwalkable: leave absent
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The raw output's shape alone — a failed reshaping has no reshaped value to walk. */
|
|
239
|
+
function observedRawShape(raw: unknown): { observedRawShape?: Shape } {
|
|
240
|
+
try {
|
|
241
|
+
return { observedRawShape: shapeOf(raw) };
|
|
242
|
+
} catch {
|
|
243
|
+
return {};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The PUBLIC code of a step refused by the content-reference guard (literal, unproven ref, or a param the gate refused). */
|
|
248
|
+
export const CONTENT_REF_REFUSED = "content-ref-refused";
|
|
249
|
+
/**
|
|
250
|
+
* The step-level refusals a CLIENT can cause. An explicit set, not a `media-arg-` prefix: the family
|
|
251
|
+
* also holds `media-arg-schema-unknown` (a host/schema defect) and `media-arg-suspect` (a hint), neither
|
|
252
|
+
* of which is a refused argument.
|
|
253
|
+
*/
|
|
254
|
+
const CONTENT_REF_REFUSAL_CODES: ReadonlySet<string> = new Set([
|
|
255
|
+
"media-arg-literal",
|
|
256
|
+
"media-arg-unproven-ref",
|
|
257
|
+
"media-arg-param-refused",
|
|
258
|
+
]);
|
|
259
|
+
const isContentRefRefusal = (f: Finding): boolean => CONTENT_REF_REFUSAL_CODES.has(f.code);
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The first finding (graph order) when every step that failed ON ITS OWN failed with the same
|
|
263
|
+
* one of `schema-drift` / `upstream-empty` — the rest were skipped as its dependents
|
|
264
|
+
* (`dependency-failed`) — or when every one of them was refused by the content-reference guard
|
|
265
|
+
* (then `content-ref-refused`, whichever `media-arg-*` case it was). `undefined` for anything else.
|
|
266
|
+
*/
|
|
267
|
+
function onlyFailure(
|
|
268
|
+
steps: readonly MCPToolNode[],
|
|
269
|
+
failed: ReadonlyMap<string, Finding>
|
|
270
|
+
): Finding | undefined {
|
|
271
|
+
let first: Finding | undefined;
|
|
272
|
+
for (const step of steps) {
|
|
273
|
+
const f = failed.get(step.stepId);
|
|
274
|
+
if (f === undefined || f.code === "dependency-failed") continue;
|
|
275
|
+
if (isContentRefRefusal(f)) {
|
|
276
|
+
if (first !== undefined && first.code !== CONTENT_REF_REFUSED) return undefined;
|
|
277
|
+
first ??= err(
|
|
278
|
+
CONTENT_REF_REFUSED,
|
|
279
|
+
"a content-reference argument was refused: a content address is a capability and only a reference the host vouches for is accepted"
|
|
280
|
+
);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (f.code !== "schema-drift" && f.code !== "upstream-empty") return undefined;
|
|
284
|
+
if (first !== undefined && f.code !== first.code) return undefined;
|
|
285
|
+
first ??= f;
|
|
286
|
+
}
|
|
287
|
+
return first;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A published value; carries `drift` only when an input drift was proceeded past. */
|
|
291
|
+
const okOutcome = (value: unknown, findings: Finding[], drift: DriftRecord[]): ProviderOutcome =>
|
|
292
|
+
drift.length > 0 ? { ok: true, value, findings, drift } : { ok: true, value, findings };
|
|
293
|
+
|
|
294
|
+
export async function executeProvider(
|
|
295
|
+
spec: ProviderSpec,
|
|
296
|
+
opts: ExecuteOptions
|
|
297
|
+
): Promise<ProviderOutcome> {
|
|
298
|
+
const findings: Finding[] = [];
|
|
299
|
+
const drift: DriftRecord[] = [];
|
|
300
|
+
const steps = spec.executionGraph;
|
|
301
|
+
if (steps.length === 0)
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
finding: err("empty-graph", `provider "${spec.handle}" has no steps`),
|
|
305
|
+
drift,
|
|
306
|
+
findings,
|
|
307
|
+
};
|
|
308
|
+
const { waves, cycle } = topoWaves(steps);
|
|
309
|
+
if (cycle.length > 0)
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
finding: err(
|
|
313
|
+
"graph-cycle",
|
|
314
|
+
`provider "${spec.handle}" has a dependency cycle through ${cycle.join(", ")}`
|
|
315
|
+
),
|
|
316
|
+
drift,
|
|
317
|
+
findings,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const registry = opts.providers ?? DEFAULT_DATA_PROVIDERS;
|
|
321
|
+
// Every kind is resolved BEFORE any step runs: an unregistered one must not leave the steps
|
|
322
|
+
// before it having called their targets for an answer nobody will publish.
|
|
323
|
+
const unregistered = [...new Set(steps.map(stepKind))].filter((k) => !registry.has(k)).sort();
|
|
324
|
+
if (unregistered.length > 0)
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
finding: err(
|
|
328
|
+
"provider-kind-unregistered",
|
|
329
|
+
`provider "${spec.handle}" has steps of kind ${unregistered.map((k) => `"${k}"`).join(", ")}, which no data provider is registered for`
|
|
330
|
+
),
|
|
331
|
+
drift,
|
|
332
|
+
findings,
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const now = opts.now ?? Date.now();
|
|
336
|
+
const supplied: LoadParams = opts.params ?? {};
|
|
337
|
+
const names = providerParams(spec);
|
|
338
|
+
const declared = new Set(names);
|
|
339
|
+
const undeclared = Object.keys(supplied)
|
|
340
|
+
.filter((k) => !declared.has(k))
|
|
341
|
+
.sort();
|
|
342
|
+
// Only the params some step declares reach a step — and the argument hook. A hook that could
|
|
343
|
+
// read an undeclared param could make the answer depend on a value no cache key carries.
|
|
344
|
+
const params = pickParams(supplied, names);
|
|
345
|
+
if (undeclared.length > 0)
|
|
346
|
+
findings.push({
|
|
347
|
+
code: "param-not-overridable",
|
|
348
|
+
severity: "warn",
|
|
349
|
+
message: `provider "${spec.handle}" declares no overridable argument named ${undeclared.map((k) => `"${k}"`).join(", ")}; ignored`,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const outputs = new Map<string, unknown>();
|
|
353
|
+
const failed = new Map<string, Finding>();
|
|
354
|
+
const warnings = new Map<string, Finding[]>();
|
|
355
|
+
const warn = (stepId: string, f: Finding): void => {
|
|
356
|
+
const list = warnings.get(stepId);
|
|
357
|
+
if (list === undefined) warnings.set(stepId, [f]);
|
|
358
|
+
else list.push(f);
|
|
359
|
+
};
|
|
360
|
+
const invokeOpts: TransientInvokeOptions = {
|
|
361
|
+
label: "loom-ui.hydrate",
|
|
362
|
+
...(opts.signal && { signal: opts.signal }),
|
|
363
|
+
...(opts.principal && { principal: opts.principal }),
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const refEnv =
|
|
367
|
+
opts.contentRefArgs === undefined
|
|
368
|
+
? undefined
|
|
369
|
+
: contentRefEnv(opts.contentRefArgs, spec, opts.acceptContentRefParam, {
|
|
370
|
+
principalId: opts.principalId,
|
|
371
|
+
principal: opts.principal,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
for (const wave of waves) {
|
|
375
|
+
await mapLimit(wave, opts.concurrency ?? 4, async (node: MCPToolNode) => {
|
|
376
|
+
if (opts.signal?.aborted) {
|
|
377
|
+
failed.set(node.stepId, err("aborted", `step "${node.stepId}" aborted before it ran`));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (node.dependsOn.some((d) => failed.has(d))) {
|
|
381
|
+
failed.set(
|
|
382
|
+
node.stepId,
|
|
383
|
+
err("dependency-failed", `step "${node.stepId}" skipped: a dependency failed`)
|
|
384
|
+
);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const args = prepareArguments(node, outputs, {
|
|
388
|
+
handle: spec.handle,
|
|
389
|
+
params,
|
|
390
|
+
now,
|
|
391
|
+
...(opts.argumentHook && { hook: opts.argumentHook }),
|
|
392
|
+
...(refEnv !== undefined && {
|
|
393
|
+
guard: (n, applied) => checkContentRefs(n, applied, refEnv).finding,
|
|
394
|
+
finish: (a) => finishContentRefArgs(node, a, refEnv.table),
|
|
395
|
+
}),
|
|
396
|
+
});
|
|
397
|
+
if (!args.ok) {
|
|
398
|
+
failed.set(node.stepId, args.finding);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
for (const f of args.findings) warn(node.stepId, f);
|
|
402
|
+
const dataProvider = registry.get(stepKind(node)) as DataProvider;
|
|
403
|
+
const call = (expectedPin: string): Promise<unknown> =>
|
|
404
|
+
dataProvider.invoke(
|
|
405
|
+
{ stepId: node.stepId, target: node.toolName, args: args.args, expectedPin },
|
|
406
|
+
opts.invoker,
|
|
407
|
+
invokeOpts
|
|
408
|
+
);
|
|
409
|
+
try {
|
|
410
|
+
outputs.set(node.stepId, await call(node.toolSchemaHash));
|
|
411
|
+
} catch (e) {
|
|
412
|
+
if (!(e instanceof TypedFailure && e.channel === "schemaDrift")) {
|
|
413
|
+
failed.set(node.stepId, stepFailure(node, e, opts.signal));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const observed = (e.payload as { observedHash?: string } | undefined)?.observedHash;
|
|
417
|
+
drift.push({
|
|
418
|
+
kind: "input",
|
|
419
|
+
handle: spec.handle,
|
|
420
|
+
stepId: node.stepId,
|
|
421
|
+
toolName: node.toolName,
|
|
422
|
+
...(node.kind !== undefined && { providerKind: node.kind }),
|
|
423
|
+
expected: node.toolSchemaHash,
|
|
424
|
+
observed: observed ?? "",
|
|
425
|
+
});
|
|
426
|
+
const proceed =
|
|
427
|
+
opts.inputDrift === "report-and-proceed" &&
|
|
428
|
+
observed !== undefined &&
|
|
429
|
+
observed !== "" &&
|
|
430
|
+
observed !== node.toolSchemaHash;
|
|
431
|
+
if (!proceed) {
|
|
432
|
+
failed.set(
|
|
433
|
+
node.stepId,
|
|
434
|
+
err(
|
|
435
|
+
"schema-drift",
|
|
436
|
+
`step "${node.stepId}" (${node.toolName}) input schema drifted from ${node.toolSchemaHash} to ${observed ?? "?"}`
|
|
437
|
+
)
|
|
438
|
+
);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
outputs.set(node.stepId, await call(observed));
|
|
443
|
+
warn(node.stepId, {
|
|
444
|
+
code: "schema-drift-proceeded",
|
|
445
|
+
severity: "warn",
|
|
446
|
+
message: `step "${node.stepId}" (${node.toolName}) input schema drifted from ${node.toolSchemaHash} to ${observed}; loaded once with the observed pin — the provider still needs its repair`,
|
|
447
|
+
});
|
|
448
|
+
} catch (retry) {
|
|
449
|
+
failed.set(node.stepId, stepFailure(node, retry, opts.signal));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// GRAPH order, not completion order: two independent steps that fail (or warn) in a race must
|
|
456
|
+
// produce the same finding list every run, or the surface's text depends on which tool was slower.
|
|
457
|
+
for (const step of steps) for (const f of warnings.get(step.stepId) ?? []) findings.push(f);
|
|
458
|
+
for (const step of steps) {
|
|
459
|
+
const f = failed.get(step.stepId);
|
|
460
|
+
if (f) findings.push(f);
|
|
461
|
+
}
|
|
462
|
+
if (failed.size > 0) {
|
|
463
|
+
// A provider that failed ONLY because of input drift (every step that failed on its own
|
|
464
|
+
// drifted; the rest were skipped as its dependents) answers with the first drift in graph
|
|
465
|
+
// order, so the surface says "waiting for a repair" rather than a generic failure. The same
|
|
466
|
+
// rule for an EMPTY upstream (every step that failed on its own found no such row upstream,
|
|
467
|
+
// `upstream-empty`): the answer is the first one, published `empty` (loom ADR 0100). Any
|
|
468
|
+
// other failure alongside either — or the two mixed — keeps the generic `provider-failed`.
|
|
469
|
+
const only = onlyFailure(steps, failed);
|
|
470
|
+
if (only !== undefined) return { ok: false, finding: only, drift, findings };
|
|
471
|
+
return {
|
|
472
|
+
ok: false,
|
|
473
|
+
finding: err(
|
|
474
|
+
"provider-failed",
|
|
475
|
+
`provider "${spec.handle}": ${failed.size} of ${steps.length} steps failed`
|
|
476
|
+
),
|
|
477
|
+
drift,
|
|
478
|
+
findings,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const terminals = terminalSteps(steps);
|
|
483
|
+
const raw =
|
|
484
|
+
terminals.length === 1
|
|
485
|
+
? outputs.get((terminals[0] as MCPToolNode).stepId)
|
|
486
|
+
: Object.fromEntries(terminals.map((t) => [t.stepId, outputs.get(t.stepId)]));
|
|
487
|
+
|
|
488
|
+
const shaped = await reshape(spec.reshaping, raw, {
|
|
489
|
+
invoker: opts.invoker,
|
|
490
|
+
...(opts.signal && { signal: opts.signal }),
|
|
491
|
+
...(opts.principal && { principal: opts.principal }),
|
|
492
|
+
...(opts.reshapers && { reshapers: opts.reshapers }),
|
|
493
|
+
});
|
|
494
|
+
if (!shaped.ok) {
|
|
495
|
+
findings.push(shaped.finding);
|
|
496
|
+
// Only a failure the OUTPUT demonstrably caused (typed by `reshape`, never read from a
|
|
497
|
+
// message) is output drift a planner can repair — so, like a failed pin, the record carries
|
|
498
|
+
// the raw output's shape and (under `captureSample`) its sample. Infrastructure and transient
|
|
499
|
+
// failures carry no `failureKind`, and stay an error with no drift.
|
|
500
|
+
if (shaped.failureKind !== undefined && opts.signal?.aborted !== true)
|
|
501
|
+
drift.push({
|
|
502
|
+
kind: "output",
|
|
503
|
+
handle: spec.handle,
|
|
504
|
+
expected: "reshape",
|
|
505
|
+
observed: shaped.finding.code,
|
|
506
|
+
reshapeFailure: shaped.finding.code,
|
|
507
|
+
reshapeFailureKind: shaped.failureKind,
|
|
508
|
+
...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
|
|
509
|
+
...observedRawShape(raw),
|
|
510
|
+
// An uncloneable output is not a data observation: no sample, even under captureSample.
|
|
511
|
+
...(opts.captureSample === true &&
|
|
512
|
+
shaped.failureKind !== "uncloneable-output" && {
|
|
513
|
+
observedSample: tainted(raw, `provider:${spec.handle}`),
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
return { ok: false, finding: shaped.finding, drift, findings };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (spec.outputCheck === "contract")
|
|
520
|
+
return checkContract(spec, shaped.value, raw, opts, drift, findings, terminals);
|
|
521
|
+
if (spec.outputShapeHash === "") {
|
|
522
|
+
findings.push({
|
|
523
|
+
code: "output-shape-unpinned",
|
|
524
|
+
severity: "warn",
|
|
525
|
+
message: `provider "${spec.handle}" declares no outputShapeHash; the value is unchecked`,
|
|
526
|
+
});
|
|
527
|
+
return okOutcome(shaped.value, findings, drift);
|
|
528
|
+
}
|
|
529
|
+
// The shaped value is tool-authored: `shapeHash` refuses a nest past its depth cap (and any
|
|
530
|
+
// value it cannot walk). An unhashable value is an UNVERIFIABLE one — reported as a failure,
|
|
531
|
+
// never published as if the pin had matched, and never as a throw out of this total function.
|
|
532
|
+
let observed: string;
|
|
533
|
+
try {
|
|
534
|
+
observed = outputShapeHashOf(shaped.value);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
const f = err(
|
|
537
|
+
"output-shape-unhashable",
|
|
538
|
+
`provider "${spec.handle}" output cannot be shape-hashed, so the pinned ${spec.outputShapeHash} cannot be checked: ${e instanceof Error ? e.message : String(e)}`
|
|
539
|
+
);
|
|
540
|
+
findings.push(f);
|
|
541
|
+
return { ok: false, finding: f, drift, findings };
|
|
542
|
+
}
|
|
543
|
+
if (observed !== spec.outputShapeHash) {
|
|
544
|
+
drift.push({
|
|
545
|
+
kind: "output",
|
|
546
|
+
handle: spec.handle,
|
|
547
|
+
expected: spec.outputShapeHash,
|
|
548
|
+
observed,
|
|
549
|
+
...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
|
|
550
|
+
...observedShapes(shaped.value, raw),
|
|
551
|
+
...(opts.captureSample === true && {
|
|
552
|
+
observedSample: tainted(raw, `provider:${spec.handle}`),
|
|
553
|
+
}),
|
|
554
|
+
});
|
|
555
|
+
const f = err(
|
|
556
|
+
"output-shape-drift",
|
|
557
|
+
`provider "${spec.handle}" output shape ${observed} differs from the pinned ${spec.outputShapeHash}`
|
|
558
|
+
);
|
|
559
|
+
findings.push(f);
|
|
560
|
+
return { ok: false, finding: f, drift, findings };
|
|
561
|
+
}
|
|
562
|
+
return okOutcome(shaped.value, findings, drift);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* `outputCheck: "contract"`: the reshaped value must satisfy the value contract of EVERY kind the
|
|
567
|
+
* handle feeds (each widget renders the same value); kinds are checked in sorted order and the
|
|
568
|
+
* first failure is the drift. `undefined` is refused explicitly — `checkKindValue` passes it as
|
|
569
|
+
* "not hydrated yet", but a load that produced nothing is not a healthy value.
|
|
570
|
+
*/
|
|
571
|
+
function checkContract(
|
|
572
|
+
spec: ProviderSpec,
|
|
573
|
+
value: unknown,
|
|
574
|
+
raw: unknown,
|
|
575
|
+
opts: ExecuteOptions,
|
|
576
|
+
drift: DriftRecord[],
|
|
577
|
+
findings: Finding[],
|
|
578
|
+
terminals: readonly MCPToolNode[]
|
|
579
|
+
): ProviderOutcome {
|
|
580
|
+
const kinds = [...new Set(opts.kinds ?? [])].sort();
|
|
581
|
+
if (kinds.length === 0) {
|
|
582
|
+
const f = err(
|
|
583
|
+
"output-contract-unbound",
|
|
584
|
+
`provider "${spec.handle}" checks its output by contract, but no bound kinds were supplied; the value cannot be checked`
|
|
585
|
+
);
|
|
586
|
+
findings.push(f);
|
|
587
|
+
return { ok: false, finding: f, drift, findings };
|
|
588
|
+
}
|
|
589
|
+
// A contract check over a kind with NO contract passes every value, and a green check nobody
|
|
590
|
+
// populated reads as coverage. So it is a refusal — and no drift record: reshaping the value
|
|
591
|
+
// differently cannot repair a missing contract, which is the catalogue's to declare.
|
|
592
|
+
const uncontracted = kinds.filter((k) => !hasKindContract(k, opts.contracts));
|
|
593
|
+
if (uncontracted.length > 0) {
|
|
594
|
+
const f = err(
|
|
595
|
+
"output-contract-missing",
|
|
596
|
+
`provider "${spec.handle}" checks its output by contract, but kind${uncontracted.length > 1 ? "s" : ""} ${uncontracted.map((k) => `"${k}"`).join(", ")} ${uncontracted.length > 1 ? "have" : "has"} no value contract (declare one in the catalogue's valueContracts); the value cannot be checked`
|
|
597
|
+
);
|
|
598
|
+
findings.push(f);
|
|
599
|
+
return { ok: false, finding: f, drift, findings };
|
|
600
|
+
}
|
|
601
|
+
for (const kind of kinds) {
|
|
602
|
+
const r =
|
|
603
|
+
value === undefined
|
|
604
|
+
? {
|
|
605
|
+
ok: false as const,
|
|
606
|
+
finding: err(
|
|
607
|
+
"value-contract",
|
|
608
|
+
`value for kind "${kind}" is undefined: the reshaping produced nothing`
|
|
609
|
+
),
|
|
610
|
+
}
|
|
611
|
+
: checkKindValue(kind, value, opts.contracts);
|
|
612
|
+
if (r.ok) continue;
|
|
613
|
+
let observed = "unhashable";
|
|
614
|
+
try {
|
|
615
|
+
observed = outputShapeHashOf(value);
|
|
616
|
+
} catch {
|
|
617
|
+
// Too deep to hash: the contract failure below is still reported.
|
|
618
|
+
}
|
|
619
|
+
drift.push({
|
|
620
|
+
kind: "output",
|
|
621
|
+
handle: spec.handle,
|
|
622
|
+
expected: `contract:${kind}`,
|
|
623
|
+
observed,
|
|
624
|
+
contractKind: kind,
|
|
625
|
+
...(terminals.length === 1 && { stepId: (terminals[0] as MCPToolNode).stepId }),
|
|
626
|
+
...observedShapes(value, raw),
|
|
627
|
+
...(opts.captureSample === true && {
|
|
628
|
+
observedSample: tainted(raw, `provider:${spec.handle}`),
|
|
629
|
+
}),
|
|
630
|
+
});
|
|
631
|
+
const f = err("output-contract-drift", `provider "${spec.handle}": ${r.finding.message}`);
|
|
632
|
+
findings.push(f);
|
|
633
|
+
return { ok: false, finding: f, drift, findings };
|
|
634
|
+
}
|
|
635
|
+
return okOutcome(value, findings, drift);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* What an outcome publishes — the ONE mapping both the in-process controller and `serveHandle`
|
|
640
|
+
* use, so split and in-process hydration cannot disagree: a value is `ready` (carrying the first
|
|
641
|
+
* warning, e.g. a proceeded drift); an `upstream-empty` failure is `empty` — checked FIRST, and
|
|
642
|
+
* carrying no value (loom ADR 0100: a producer MUST clear the value beside `empty`); a failure
|
|
643
|
+
* with drift is `stale` — unless its only drift is a failed reshaping (`reshapeFailure`), which
|
|
644
|
+
* stays `error` as it always was — and any other failure `error`.
|
|
645
|
+
*/
|
|
646
|
+
export function outcomeResult(outcome: ProviderOutcome): HandleLoadResult {
|
|
647
|
+
if (outcome.ok) {
|
|
648
|
+
const warn = outcome.findings.find((f) => f.severity === "warn");
|
|
649
|
+
return warn === undefined
|
|
650
|
+
? { state: "ready", value: outcome.value }
|
|
651
|
+
: { state: "ready", value: outcome.value, finding: warn };
|
|
652
|
+
}
|
|
653
|
+
if (outcome.finding.code === "upstream-empty")
|
|
654
|
+
return { state: "empty", finding: outcome.finding };
|
|
655
|
+
const stale = outcome.drift.some((d) => d.reshapeFailure === undefined);
|
|
656
|
+
return { state: stale ? "stale" : "error", finding: outcome.finding };
|
|
657
|
+
}
|