@loomwork/ui-hydration 0.0.0-stage → 0.1.1
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 +264 -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 +259 -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 +312 -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 +135 -0
- package/dist/split/client.d.ts.map +1 -0
- package/dist/split/client.js +316 -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 +378 -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 +452 -0
- package/src/split/index.ts +18 -0
- package/src/split/server.ts +746 -0
- package/src/split/shared.ts +18 -0
package/src/repair.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Finding, Operation, ProviderSpec } from "@loomwork/ui-core";
|
|
2
|
+
import type { DriftRecord } from "./provider";
|
|
3
|
+
|
|
4
|
+
// ── Crossing the determinism boundary ─────────────────────────────────────────
|
|
5
|
+
// Hydration REPORTS drift; it never repairs. The app runs a durable loom execution that applies
|
|
6
|
+
// the operation built here (via core's `apply`, so the artifact's lineage records it), emits
|
|
7
|
+
// SCHEMA_REPAIRED with this payload, and re-seals. Input drift (a tool's input schema moved) is a
|
|
8
|
+
// pin move and can be remapped mechanically; output drift (the reshaped value's shape changed)
|
|
9
|
+
// means the reshaping is wrong for the data and needs the planner — that is a different slice.
|
|
10
|
+
|
|
11
|
+
export const SCHEMA_REPAIRED = "SCHEMA_REPAIRED";
|
|
12
|
+
|
|
13
|
+
export interface SchemaRepairedPayload {
|
|
14
|
+
readonly experienceId: string;
|
|
15
|
+
readonly versionHash: string;
|
|
16
|
+
readonly handle: string;
|
|
17
|
+
readonly drift: readonly DriftRecord[];
|
|
18
|
+
readonly op: Operation;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function remapOperation(
|
|
22
|
+
spec: ProviderSpec,
|
|
23
|
+
drift: readonly DriftRecord[]
|
|
24
|
+
): { ok: true; op: Operation } | { ok: false; finding: Finding } {
|
|
25
|
+
if (drift.length === 0)
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
finding: { code: "repair-mismatch", severity: "error", message: "no drift to repair" },
|
|
29
|
+
};
|
|
30
|
+
if (drift.some((d) => d.kind === "output"))
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
finding: {
|
|
34
|
+
code: "repair-needs-planner",
|
|
35
|
+
severity: "error",
|
|
36
|
+
message: `provider "${spec.handle}": output shape drifted; remapping a pin cannot fix a reshaping that no longer matches the data`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const steps = new Map(spec.executionGraph.map((s) => [s.stepId, s] as const));
|
|
40
|
+
for (const d of drift) {
|
|
41
|
+
if (d.handle !== spec.handle)
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
finding: {
|
|
45
|
+
code: "repair-mismatch",
|
|
46
|
+
severity: "error",
|
|
47
|
+
message: `drift for "${d.handle}" applied to provider "${spec.handle}"`,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
if (d.stepId === undefined || !steps.has(d.stepId))
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
finding: {
|
|
54
|
+
code: "repair-mismatch",
|
|
55
|
+
severity: "error",
|
|
56
|
+
message: `drift names step "${d.stepId ?? "?"}", which provider "${spec.handle}" does not have`,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const observedByStep = new Map(drift.map((d) => [d.stepId as string, d.observed]));
|
|
61
|
+
const executionGraph = spec.executionGraph.map((s) =>
|
|
62
|
+
observedByStep.has(s.stepId)
|
|
63
|
+
? { ...s, toolSchemaHash: observedByStep.get(s.stepId) as string }
|
|
64
|
+
: s
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
op: { type: "remapProvider", handle: spec.handle, spec: { ...spec, executionGraph } },
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/reshape.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { TypedFailure } from "@loomwork/core";
|
|
2
|
+
import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
|
|
3
|
+
import { type SANDBOX_ERRORS, SANDBOX_TOOL_ID } from "@loomwork/plugin-sandbox";
|
|
4
|
+
import type { Finding, ProviderSpec } from "@loomwork/ui-core";
|
|
5
|
+
import type { TransientInvoker } from "./invoker";
|
|
6
|
+
import { type ReshapeFailureKind, type ReshapeResult, jsonPathSelect } from "./select";
|
|
7
|
+
|
|
8
|
+
// ── Reshaping ─────────────────────────────────────────────────────────────────
|
|
9
|
+
// `QuickJS`: a content-addressed script runs in loom's sandbox (the effect-local middleware,
|
|
10
|
+
// limits and typed channels apply; nothing is logged). `JSONPath`: the scriptRef IS the path,
|
|
11
|
+
// selected inline — see the ProviderSpec.reshaping comment in core for the convention. `Named`:
|
|
12
|
+
// the scriptRef names a reshaper the HOST registered (`defineReshapers`); it runs in-process as
|
|
13
|
+
// host code, trusted like the host, with no sandbox hop. Each engine has its own branch: an
|
|
14
|
+
// engine this function does not know is never sent to the sandbox by default.
|
|
15
|
+
|
|
16
|
+
/** A channel loom's sandbox declares (`SANDBOX_ERRORS`) — a rename there fails to compile here. */
|
|
17
|
+
type SandboxChannel = keyof typeof SANDBOX_ERRORS;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The channel loom's sandbox completes on when the SCRIPT itself threw on its input (its own
|
|
21
|
+
* exception, a SyntaxError, a ReferenceError for a global the VM lacks). Recognised by channel
|
|
22
|
+
* only: its payload (`{ name?, message }`) is script-authored, UNTRUSTED text — it can echo the
|
|
23
|
+
* raw output — so it is never read, and never copied into a finding message.
|
|
24
|
+
*/
|
|
25
|
+
const SCRIPT_ERROR: SandboxChannel = "scriptError";
|
|
26
|
+
const LIMIT_EXCEEDED: SandboxChannel = "limitExceeded";
|
|
27
|
+
|
|
28
|
+
/** A sandbox `TypedFailure` → the output-caused kind it proves, if any. Channel + payload only. */
|
|
29
|
+
function sandboxFailureKind(e: TypedFailure): ReshapeFailureKind | undefined {
|
|
30
|
+
if (e.channel === SCRIPT_ERROR) return "script-error";
|
|
31
|
+
if (e.channel !== LIMIT_EXCEEDED) return undefined;
|
|
32
|
+
const kind = (e.payload as { kind?: unknown } | null | undefined)?.kind;
|
|
33
|
+
if (kind === "cpu") return "limit-cpu";
|
|
34
|
+
if (kind === "memory") return "limit-memory";
|
|
35
|
+
if (kind === "output") return "limit-output";
|
|
36
|
+
return undefined; // wallClock (host-load dependent) or an unknown kind
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A host-registered reshaper: the raw terminal output in, the widget's value out. */
|
|
40
|
+
export type Reshaper = (input: unknown) => unknown | Promise<unknown>;
|
|
41
|
+
|
|
42
|
+
/** Where `Named` reshaping looks a name up. Injected — hydration registers nothing itself. */
|
|
43
|
+
export interface ReshaperRegistry {
|
|
44
|
+
get(name: string): Reshaper | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A registry over a plain record. Map-backed, so only the record's OWN entries are registered —
|
|
49
|
+
* `"constructor"` or `"__proto__"` never resolve to something on a prototype.
|
|
50
|
+
*/
|
|
51
|
+
export function defineReshapers(entries: Readonly<Record<string, Reshaper>>): ReshaperRegistry {
|
|
52
|
+
const byName = new Map(Object.entries(entries));
|
|
53
|
+
return { get: (name) => byName.get(name) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A Named reshaper runs in-process, trusted like host code — but the RAW terminal output is also
|
|
58
|
+
* read by callers after `reshape` returns (output-shape hashing, drift sampling): a reshaper that
|
|
59
|
+
* mutates its input in place must never corrupt what those later readers see, so it gets a copy,
|
|
60
|
+
* never the original reference. A raw value `structuredClone` cannot represent anywhere in its
|
|
61
|
+
* tree (a function; `structuredClone` throws) is REFUSED rather than handed to the reshaper live
|
|
62
|
+
* — that would let the reshaper corrupt what later readers see, exactly what the copy exists to
|
|
63
|
+
* prevent. (A class instance is not this case: `structuredClone` clones it fine, it just drops
|
|
64
|
+
* the prototype, so the copy is a plain object with the same own properties.) Throws; `runNamed`
|
|
65
|
+
* turns that into a `reshape-failed` finding before the reshaper ever runs.
|
|
66
|
+
*/
|
|
67
|
+
function cloneForReshaper(raw: unknown): unknown {
|
|
68
|
+
try {
|
|
69
|
+
return structuredClone(raw);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("input is not cloneable");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runNamed(
|
|
76
|
+
name: string,
|
|
77
|
+
raw: unknown,
|
|
78
|
+
registry: ReshaperRegistry | undefined
|
|
79
|
+
): Promise<ReshapeResult> {
|
|
80
|
+
const fn = registry?.get(name);
|
|
81
|
+
if (fn === undefined)
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
finding: {
|
|
85
|
+
code: "reshape-unregistered",
|
|
86
|
+
severity: "error",
|
|
87
|
+
message:
|
|
88
|
+
registry === undefined
|
|
89
|
+
? `reshaper "${name}" is Named, but no reshaper registry was supplied`
|
|
90
|
+
: `no reshaper is registered under "${name}"`,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
let cloned: unknown;
|
|
94
|
+
try {
|
|
95
|
+
cloned = cloneForReshaper(raw);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// The OUTPUT is the cause: it holds a value that cannot be copied, so the reshaper never runs.
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
failureKind: "uncloneable-output",
|
|
101
|
+
finding: {
|
|
102
|
+
code: "reshape-failed",
|
|
103
|
+
severity: "error",
|
|
104
|
+
message: `reshaper "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return { ok: true, value: await fn(cloned) };
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Host code threw: not proven output-caused, so no kind — a plain error, not drift.
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
finding: {
|
|
115
|
+
code: "reshape-failed",
|
|
116
|
+
severity: "error",
|
|
117
|
+
message: `reshaper "${name}" failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A finding MESSAGE must never be the thing that throws. A `TypedFailure` payload is authored by
|
|
125
|
+
* a tool server or a sandbox script — it can be cyclic, or carry a bigint, and `JSON.stringify`
|
|
126
|
+
* throws on both. Losing the payload text is a far better outcome than losing the finding.
|
|
127
|
+
* (Not exported from the package index: it exists for this package's message building.)
|
|
128
|
+
*/
|
|
129
|
+
export function safeStringify(v: unknown): string {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.stringify(v) ?? String(v);
|
|
132
|
+
} catch {
|
|
133
|
+
return "[unserializable payload]";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function reshape(
|
|
138
|
+
reshaping: ProviderSpec["reshaping"],
|
|
139
|
+
raw: unknown,
|
|
140
|
+
ctx: {
|
|
141
|
+
invoker: TransientInvoker;
|
|
142
|
+
signal?: AbortSignal;
|
|
143
|
+
principal?: TransientInvokeOptions["principal"];
|
|
144
|
+
reshapers?: ReshaperRegistry;
|
|
145
|
+
}
|
|
146
|
+
): Promise<ReshapeResult> {
|
|
147
|
+
if (reshaping.engine === "JSONPath") return jsonPathSelect(raw, reshaping.scriptRef);
|
|
148
|
+
if (reshaping.engine === "Named") return runNamed(reshaping.scriptRef, raw, ctx.reshapers);
|
|
149
|
+
try {
|
|
150
|
+
const value = await ctx.invoker.invokeTransient(
|
|
151
|
+
SANDBOX_TOOL_ID,
|
|
152
|
+
{ scriptRef: { __loomContent: reshaping.scriptRef }, input: raw },
|
|
153
|
+
{
|
|
154
|
+
label: "loom-ui.reshape",
|
|
155
|
+
...(ctx.signal && { signal: ctx.signal }),
|
|
156
|
+
...(ctx.principal && { principal: ctx.principal }),
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
return { ok: true, value };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (err instanceof TypedFailure) {
|
|
162
|
+
const failureKind = sandboxFailureKind(err);
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
...(failureKind !== undefined && { failureKind }),
|
|
166
|
+
finding: {
|
|
167
|
+
code: `reshape-${err.channel}`,
|
|
168
|
+
severity: "error",
|
|
169
|
+
// A `scriptError` payload is script-authored text that can echo the raw output
|
|
170
|
+
// (possibly sensitive, untrusted): fixed text only, never its `name` or `message` —
|
|
171
|
+
// no finding, handle status or log hook ever carries it. The raw output travels only
|
|
172
|
+
// as the tainted drift sample. Other channels' payloads are loom-authored.
|
|
173
|
+
message:
|
|
174
|
+
failureKind === "script-error"
|
|
175
|
+
? `reshape script ${reshaping.scriptRef} failed on the output (script-error)`
|
|
176
|
+
: `sandbox ${err.channel}: ${safeStringify(err.payload)}`,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
finding: {
|
|
183
|
+
code: "reshape-failed",
|
|
184
|
+
severity: "error",
|
|
185
|
+
message: err instanceof Error ? err.message : String(err),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { outputShapeHashOf } from "./shape";
|
|
192
|
+
export { jsonPathSelect } from "./select";
|
|
193
|
+
export type { ReshapeFailureKind, ReshapeResult } from "./select";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Audience,
|
|
3
|
+
type Finding,
|
|
4
|
+
type HandleUse,
|
|
5
|
+
type Viewer,
|
|
6
|
+
effectiveViewer,
|
|
7
|
+
isBroadcast,
|
|
8
|
+
mayView,
|
|
9
|
+
} from "@loomwork/ui-core";
|
|
10
|
+
|
|
11
|
+
// ── Viewer enforcement at the data boundary ───────────────────────────────────
|
|
12
|
+
// Hydration is where data actually flows, so it is the second place a binding's `sensitivity`
|
|
13
|
+
// is enforced (the first is `compile`, which redacts the node). A handle whose strictest class the
|
|
14
|
+
// viewer may not see is never planned, never invoked, never cached, never served: its provider
|
|
15
|
+
// is not called at all. FAIL-CLOSED: no viewer is a viewer with no clearance; unclassified handles
|
|
16
|
+
// are unaffected either way. See `@loomwork/ui-core`'s `viewer.ts`.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the store (and so the viewer) is told about a refused handle. A fixed text: it names no
|
|
20
|
+
* handle, class, tool or argument, so refusing leaks nothing beyond "this is restricted".
|
|
21
|
+
*/
|
|
22
|
+
export const restrictedFinding = (): Finding => ({
|
|
23
|
+
code: "restricted",
|
|
24
|
+
severity: "error",
|
|
25
|
+
message: "this data is restricted for the current viewer",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The advisory for classified handles with no viewer supplied (they are all refused). */
|
|
29
|
+
export const viewerNotSuppliedFinding = (): Finding => ({
|
|
30
|
+
code: "viewer-not-supplied",
|
|
31
|
+
severity: "warn",
|
|
32
|
+
message:
|
|
33
|
+
"the experience binds classified handles and no viewer was supplied: they are refused (no viewer is the most restrictive viewer)",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** Split an inventory into the handles a viewer may load and the ones it may not. */
|
|
37
|
+
export function partitionByViewer(
|
|
38
|
+
uses: readonly HandleUse[],
|
|
39
|
+
viewer: Viewer | undefined
|
|
40
|
+
): { allowed: HandleUse[]; restricted: HandleUse[]; advisory: Finding[] } {
|
|
41
|
+
const allowed: HandleUse[] = [];
|
|
42
|
+
const restricted: HandleUse[] = [];
|
|
43
|
+
for (const use of uses) (mayView(viewer, use.sensitivity) ? allowed : restricted).push(use);
|
|
44
|
+
const classified = uses.some((u) => u.sensitivity !== undefined);
|
|
45
|
+
return {
|
|
46
|
+
allowed,
|
|
47
|
+
restricted,
|
|
48
|
+
advisory: classified && viewer === undefined ? [viewerNotSuppliedFinding()] : [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A copy of the viewer holding only a clearance read once; a throwing getter reads as none. For a
|
|
54
|
+
* broadcast `audience` the result is the audience's viewer (no clearance, whoever asked: the
|
|
55
|
+
* viewer is not even read), so a handle compile withheld for the audience is refused here too.
|
|
56
|
+
*/
|
|
57
|
+
export function snapshotViewer(
|
|
58
|
+
viewer: Viewer | undefined,
|
|
59
|
+
audience?: Audience
|
|
60
|
+
): Viewer | undefined {
|
|
61
|
+
if (isBroadcast(audience)) return effectiveViewer(undefined, audience);
|
|
62
|
+
if (viewer === undefined) return undefined;
|
|
63
|
+
try {
|
|
64
|
+
const clearance = viewer.clearance;
|
|
65
|
+
return clearance === undefined ? {} : { clearance };
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import type { Finding, Priority } from "@loomwork/ui-core";
|
|
2
|
+
import type { HydrationStore } from "./projection";
|
|
3
|
+
|
|
4
|
+
// ── Handle scheduling, independent of WHERE a handle is loaded ────────────────
|
|
5
|
+
// What both controllers share: each handle loaded once per QUESTION (a job's fingerprint), at its
|
|
6
|
+
// priority — P0 now, P1 collected in a batching window, P2 held until the surface says the widget
|
|
7
|
+
// is visible — superseded loads aborted and their late results dropped, and nothing ever leaking
|
|
8
|
+
// a rejection (`start` publishes its own failures as an `error` status). HOW a job loads is the
|
|
9
|
+
// owner's `run`: in-process through `executeProvider`, or across a trust boundary through a
|
|
10
|
+
// `HandleLoader`. Jobs started together share one GROUP (the in-process controller's pass, which
|
|
11
|
+
// fixes the clock and memoizes identical steps across the handles in it). Clock and timers are
|
|
12
|
+
// injected: scheduling is a pure function of the calls made.
|
|
13
|
+
//
|
|
14
|
+
// A re-queued P2 (its question changed, or it was invalidated) goes back to waiting for the
|
|
15
|
+
// surface's NEXT `markVisible`: invalidating must not fetch data for something off-screen, so a
|
|
16
|
+
// surface re-reports visibility for widgets it still shows.
|
|
17
|
+
|
|
18
|
+
export interface ScheduledJob {
|
|
19
|
+
readonly handle: string;
|
|
20
|
+
readonly priority: Priority;
|
|
21
|
+
/** The identity of the question. Same fingerprint + a live status = already asked; no re-run. */
|
|
22
|
+
readonly fingerprint: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a load publishes. Serializable: it is also what a `HandleLoader` returns across a boundary. */
|
|
26
|
+
export type HandleLoadResult =
|
|
27
|
+
| { readonly state: "ready"; readonly value: unknown; readonly finding?: Finding }
|
|
28
|
+
| { readonly state: "stale" | "error"; readonly finding: Finding }
|
|
29
|
+
/** Delivered, and the upstream has no such row (`upstream-empty`): no value (loom ADR 0100). */
|
|
30
|
+
| { readonly state: "empty"; readonly finding: Finding };
|
|
31
|
+
|
|
32
|
+
export interface SchedulerHooks<J extends ScheduledJob, G, R extends HandleLoadResult> {
|
|
33
|
+
newGroup(): G;
|
|
34
|
+
run(job: J, signal: AbortSignal, group: G): Promise<R>;
|
|
35
|
+
/** A value that needs no load (a cache hit): published `ready` at once. */
|
|
36
|
+
cached?(job: J): { readonly value: unknown } | undefined;
|
|
37
|
+
/** The result is current (not superseded, not aborted) and about to be published. */
|
|
38
|
+
commit?(job: J, result: R): void;
|
|
39
|
+
/**
|
|
40
|
+
* The result was published. A throw here is swallowed: the handle keeps the status just
|
|
41
|
+
* published (the load is no longer in flight, so it is not moved to an error status) and
|
|
42
|
+
* nothing rejects.
|
|
43
|
+
*/
|
|
44
|
+
published?(job: J, result: R): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TimerFn = (fn: () => void, ms: number) => () => void;
|
|
48
|
+
|
|
49
|
+
export const defaultTimer: TimerFn = (fn, ms) => {
|
|
50
|
+
const t = setTimeout(fn, ms);
|
|
51
|
+
return () => clearTimeout(t);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** NUL: cannot occur in a handle or a hash, so joined fingerprints never collide. */
|
|
55
|
+
export const KEY_SEP = String.fromCharCode(0);
|
|
56
|
+
|
|
57
|
+
export const disposedFinding = (what: string): Finding => ({
|
|
58
|
+
code: "disposed",
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: `the hydration controller is disposed; ${what} does nothing`,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export const tooDeep = (experienceId: string, max: number): Finding => ({
|
|
64
|
+
code: "tree-too-deep",
|
|
65
|
+
severity: "error",
|
|
66
|
+
message: `experience "${experienceId}" exceeds MAX_TREE_DEPTH (${max})`,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export class HandleScheduler<J extends ScheduledJob, G, R extends HandleLoadResult> {
|
|
70
|
+
private readonly inflight = new Map<string, AbortController>();
|
|
71
|
+
/**
|
|
72
|
+
* P1 jobs waiting for the batch timer, ONE per handle: a question changed inside the window
|
|
73
|
+
* replaces the queued one (the old one would otherwise start first and wedge the new one).
|
|
74
|
+
*/
|
|
75
|
+
private pending = new Map<string, J>();
|
|
76
|
+
private cancelBatch: (() => void) | undefined;
|
|
77
|
+
private readonly lazy = new Map<string, J>();
|
|
78
|
+
/** handle → fingerprint of the job it was last STARTED with. Pruned with the handle. */
|
|
79
|
+
private readonly loaded = new Map<string, string>();
|
|
80
|
+
private disposed = false;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
private readonly store: HydrationStore,
|
|
84
|
+
private readonly hooks: SchedulerHooks<J, G, R>,
|
|
85
|
+
private readonly setTimer: TimerFn = defaultTimer,
|
|
86
|
+
private readonly batchWindowMs = 50
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
get isDisposed(): boolean {
|
|
90
|
+
return this.disposed;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Schedule by priority; a job whose question is already answered (or being answered) is skipped. */
|
|
94
|
+
schedule(jobs: readonly J[]): void {
|
|
95
|
+
if (this.disposed) return;
|
|
96
|
+
// One group for every job this call starts now (a P1 batch and a markVisible get their own).
|
|
97
|
+
const now = this.lazyGroup();
|
|
98
|
+
for (const job of jobs) this.scheduleOne(job, now);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Keep only these handles: abort, dequeue, forget and unpublish every other one. */
|
|
102
|
+
retain(keep: ReadonlySet<string>): void {
|
|
103
|
+
const drop = new Set<string>();
|
|
104
|
+
for (const handle of [
|
|
105
|
+
...this.inflight.keys(),
|
|
106
|
+
...this.lazy.keys(),
|
|
107
|
+
...this.pending.keys(),
|
|
108
|
+
...this.store.handles(),
|
|
109
|
+
])
|
|
110
|
+
if (!keep.has(handle)) drop.add(handle);
|
|
111
|
+
if (drop.size === 0) return;
|
|
112
|
+
for (const handle of drop) {
|
|
113
|
+
this.inflight.get(handle)?.abort();
|
|
114
|
+
this.inflight.delete(handle);
|
|
115
|
+
this.lazy.delete(handle);
|
|
116
|
+
this.pending.delete(handle);
|
|
117
|
+
this.loaded.delete(handle);
|
|
118
|
+
if (this.store.status(handle) !== undefined) this.store.remove(handle);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ask again even if already asked: each in-flight load is superseded and a fresh one starts at
|
|
124
|
+
* once (one group). A lazy job stays lazy — its status untouched, its last value on screen.
|
|
125
|
+
*/
|
|
126
|
+
reload(jobs: readonly J[]): void {
|
|
127
|
+
if (this.disposed) return;
|
|
128
|
+
const group = this.lazyGroup();
|
|
129
|
+
for (const job of jobs) {
|
|
130
|
+
this.inflight.get(job.handle)?.abort();
|
|
131
|
+
this.inflight.delete(job.handle);
|
|
132
|
+
this.pending.delete(job.handle); // started below (or re-queued lazy): not a second time
|
|
133
|
+
if (job.priority === "P2") {
|
|
134
|
+
this.lazy.set(job.handle, job);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
this.start(job, group()).catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The surface reports a lazy (P2) widget entered the viewport. */
|
|
142
|
+
markVisible(handle: string): void {
|
|
143
|
+
if (this.disposed) return;
|
|
144
|
+
const job = this.lazy.get(handle);
|
|
145
|
+
if (!job) return;
|
|
146
|
+
this.lazy.delete(handle);
|
|
147
|
+
this.start(job, this.hooks.newGroup()).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
inFlight(): string[] {
|
|
151
|
+
return [...this.inflight.keys()].sort();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Terminal: every in-flight or queued load ends on an error status, and nothing schedules again. */
|
|
155
|
+
dispose(): void {
|
|
156
|
+
if (this.disposed) return;
|
|
157
|
+
this.disposed = true;
|
|
158
|
+
for (const [handle, ac] of this.inflight) {
|
|
159
|
+
ac.abort();
|
|
160
|
+
// The widget must not sit on a spinner forever because its controller went away.
|
|
161
|
+
this.store.setStatus(handle, {
|
|
162
|
+
state: "error",
|
|
163
|
+
finding: {
|
|
164
|
+
code: "disposed",
|
|
165
|
+
severity: "error",
|
|
166
|
+
message: `hydration for "${handle}" was disposed while in flight`,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
this.inflight.clear();
|
|
171
|
+
this.cancelBatch?.();
|
|
172
|
+
this.cancelBatch = undefined;
|
|
173
|
+
for (const job of this.pending.values()) {
|
|
174
|
+
// Already published `pending`; leaving it there would spin the widget forever waiting on a
|
|
175
|
+
// batch timer that just got cancelled. Lazy jobs were never published: nothing to terminate.
|
|
176
|
+
this.store.setStatus(job.handle, {
|
|
177
|
+
state: "error",
|
|
178
|
+
finding: {
|
|
179
|
+
code: "disposed",
|
|
180
|
+
severity: "error",
|
|
181
|
+
message: `hydration for "${job.handle}" was disposed while queued`,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
this.pending.clear();
|
|
186
|
+
this.lazy.clear();
|
|
187
|
+
this.loaded.clear();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A group made on first use, then shared: a call that starts nothing makes none. */
|
|
191
|
+
private lazyGroup(): () => G {
|
|
192
|
+
let made: { readonly group: G } | undefined;
|
|
193
|
+
return () => {
|
|
194
|
+
if (made === undefined) made = { group: this.hooks.newGroup() };
|
|
195
|
+
return made.group;
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private scheduleOne(job: J, group: () => G): void {
|
|
200
|
+
const st = this.store.status(job.handle)?.state;
|
|
201
|
+
if (this.loaded.get(job.handle) === job.fingerprint) {
|
|
202
|
+
// Same question, already asked: loading, loaded (an `empty` answer is an answer), or
|
|
203
|
+
// drifted-and-awaiting-repair.
|
|
204
|
+
if (st === "ready" || st === "pending" || st === "stale" || st === "empty") return;
|
|
205
|
+
} else {
|
|
206
|
+
// A different question for this handle: whatever is in flight answers the OLD one.
|
|
207
|
+
this.inflight.get(job.handle)?.abort();
|
|
208
|
+
this.inflight.delete(job.handle);
|
|
209
|
+
}
|
|
210
|
+
// A job queued for the batch answers the old question (or the old priority): this one owns it.
|
|
211
|
+
this.pending.delete(job.handle);
|
|
212
|
+
if (job.priority === "P2") {
|
|
213
|
+
this.lazy.set(job.handle, job);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
this.lazy.delete(job.handle); // promoted out of P2: the lazy entry has a new owner
|
|
217
|
+
if (job.priority === "P0") {
|
|
218
|
+
this.start(job, group()).catch(() => {});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.store.setStatus(job.handle, { state: "pending" });
|
|
222
|
+
// Keyed by handle: a changed question REPLACES the queued one rather than queueing behind it.
|
|
223
|
+
this.pending.set(job.handle, job);
|
|
224
|
+
this.cancelBatch ??= this.setTimer(() => {
|
|
225
|
+
this.cancelBatch = undefined;
|
|
226
|
+
const batch = [...this.pending.values()];
|
|
227
|
+
this.pending.clear();
|
|
228
|
+
if (batch.length === 0) return;
|
|
229
|
+
const g = this.hooks.newGroup();
|
|
230
|
+
for (const j of batch) this.start(j, g).catch(() => {});
|
|
231
|
+
}, this.batchWindowMs);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async start(job: J, group: G): Promise<void> {
|
|
235
|
+
const { handle } = job;
|
|
236
|
+
if (this.disposed) return;
|
|
237
|
+
this.loaded.set(handle, job.fingerprint);
|
|
238
|
+
const cached = this.hooks.cached?.(job);
|
|
239
|
+
if (cached) {
|
|
240
|
+
this.inflight.delete(handle);
|
|
241
|
+
this.store.setValue(handle, cached.value, { state: "ready" });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (this.inflight.has(handle)) return;
|
|
245
|
+
const ac = new AbortController();
|
|
246
|
+
this.inflight.set(handle, ac);
|
|
247
|
+
try {
|
|
248
|
+
this.store.setStatus(handle, { state: "pending" });
|
|
249
|
+
const result = await this.hooks.run(job, ac.signal, group);
|
|
250
|
+
if (this.inflight.get(handle) !== ac || ac.signal.aborted) return; // superseded or aborted
|
|
251
|
+
this.inflight.delete(handle);
|
|
252
|
+
this.hooks.commit?.(job, result);
|
|
253
|
+
if (result.state === "ready")
|
|
254
|
+
this.store.setValue(handle, result.value, {
|
|
255
|
+
state: "ready",
|
|
256
|
+
...(result.finding && { finding: result.finding }),
|
|
257
|
+
});
|
|
258
|
+
else this.store.setStatus(handle, { state: result.state, finding: result.finding });
|
|
259
|
+
this.hooks.published?.(job, result);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// `run` is total by construction — but a loader or invoker that rejects with a value whose
|
|
262
|
+
// message getter throws, a store subscriber that throws synchronously on ANY publish here
|
|
263
|
+
// (including the first `pending`), or any future defect must land on the widget as an error
|
|
264
|
+
// status, never as an unhandled rejection that takes the host process down.
|
|
265
|
+
if (this.inflight.get(handle) !== ac || ac.signal.aborted) return;
|
|
266
|
+
this.inflight.delete(handle);
|
|
267
|
+
try {
|
|
268
|
+
// The publish below can re-trigger the very subscriber whose throw got us here. A second
|
|
269
|
+
// throw must not escape: `inflight` is already cleaned up, which is the invariant
|
|
270
|
+
// `dispose`/`retain`/`inFlight` depend on.
|
|
271
|
+
this.store.setStatus(handle, {
|
|
272
|
+
state: "error",
|
|
273
|
+
finding: {
|
|
274
|
+
code: "hydration-internal",
|
|
275
|
+
severity: "error",
|
|
276
|
+
message: e instanceof Error ? e.message : String(e),
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
} catch {
|
|
280
|
+
// Ignored — see comment above.
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/select.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Finding } from "@loomwork/ui-core";
|
|
2
|
+
import { resolvePath } from "./plan";
|
|
3
|
+
|
|
4
|
+
// Pure reshaping pieces (no sandbox, no MCP): `@loomwork/ui-hydration/client` exports them so a planner
|
|
5
|
+
// can dry-run a JSONPath binding without the server plane. `reshape.ts` re-exports them.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why a reshaping failed, when the tool OUTPUT demonstrably caused it — the failures a re-authored
|
|
9
|
+
* binding can repair. Typed from the failure's channel and payload, never from message text:
|
|
10
|
+
*
|
|
11
|
+
* - `script-error`: the sandbox reports the script threw on this input (a `TypedFailure` on loom's
|
|
12
|
+
* `scriptError` channel, `SANDBOX_ERRORS.scriptError`).
|
|
13
|
+
* - `selects-nothing`: a JSONPath selects nothing from the output.
|
|
14
|
+
* - `uncloneable-output`: the output holds a value `structuredClone` cannot copy (a function, say),
|
|
15
|
+
* so a Named reshaper is never handed it. Recorded WITHOUT a sample: such a value is not a data
|
|
16
|
+
* observation a planner can author against.
|
|
17
|
+
* - `limit-cpu`, `limit-memory`, `limit-output`: a deterministic resource limit the script hit on
|
|
18
|
+
* this input. `limit-cpu` is measured against the host's instruction-count `interruptBudget`,
|
|
19
|
+
* not time — deterministic for a given budget, so LOWERING the budget surfaces as output drift.
|
|
20
|
+
*
|
|
21
|
+
* Anything else has no kind and is not drift. That includes a `wallClock` limit (host-load
|
|
22
|
+
* dependent — on a loaded host a script can trip `wallClock` before `cpu`, and then no drift is
|
|
23
|
+
* reported that time), a Named reshaper's own throw (host code: a host bug or its own I/O is not
|
|
24
|
+
* proven output-caused, and must never get the host's reshaper replaced by a model-authored
|
|
25
|
+
* binding), a missing script, an unregistered reshaper, an untyped throw (transport, worker pool,
|
|
26
|
+
* integrity), an authorization or quota denial, and an abort.
|
|
27
|
+
*/
|
|
28
|
+
export type ReshapeFailureKind =
|
|
29
|
+
| "script-error"
|
|
30
|
+
| "selects-nothing"
|
|
31
|
+
| "uncloneable-output"
|
|
32
|
+
| "limit-cpu"
|
|
33
|
+
| "limit-memory"
|
|
34
|
+
| "limit-output";
|
|
35
|
+
|
|
36
|
+
export type ReshapeResult =
|
|
37
|
+
| { ok: true; value: unknown }
|
|
38
|
+
| {
|
|
39
|
+
ok: false;
|
|
40
|
+
finding: Finding;
|
|
41
|
+
/** Set only when the output demonstrably caused the failure (see `ReshapeFailureKind`). */
|
|
42
|
+
failureKind?: ReshapeFailureKind;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export function jsonPathSelect(value: unknown, path: string): ReshapeResult {
|
|
46
|
+
if (path === "" || path === "$") return { ok: true, value };
|
|
47
|
+
const out = resolvePath(value, path);
|
|
48
|
+
if (out === undefined)
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
failureKind: "selects-nothing",
|
|
52
|
+
finding: {
|
|
53
|
+
code: "reshape-failed",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: `JSONPath "${path}" selects nothing from the tool output`,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return { ok: true, value: out };
|
|
59
|
+
}
|