@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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { TypedFailure } from "@loomwork/core";
|
|
2
|
+
import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
|
|
3
|
+
import { MCP_INVOKE_TOOL_ID } from "@loomwork/plugin-mcp";
|
|
4
|
+
import { DEFAULT_STEP_KIND, STEP_KIND_PATTERN } from "@loomwork/ui-core";
|
|
5
|
+
import type { TransientInvoker } from "./invoker";
|
|
6
|
+
|
|
7
|
+
// ── The data plane: a step's KIND names the DataProvider that runs it (U3) ───────
|
|
8
|
+
// A provider's graph steps used to be MCP tool calls and nothing else, so a loom app whose data
|
|
9
|
+
// lives in a loom tool, a read model or an A2A peer had to wrap it as an MCP server to bind a
|
|
10
|
+
// widget. A step now names a KIND (`step.kind`, absent = MCP); hydration looks the kind up in a
|
|
11
|
+
// `DataProviderRegistry` the HOST builds at composition time — there is no runtime plugin system,
|
|
12
|
+
// no discovery, no registering after the first load. MCP is the first registered kind and the
|
|
13
|
+
// default, and behaves byte-for-byte as before.
|
|
14
|
+
//
|
|
15
|
+
// What a kind supplies (`DataProvider.invoke`) is ONE call: run `target` with `args`, THROUGH the
|
|
16
|
+
// transient invoker hydration was given — which is what keeps the read path log-free and keeps
|
|
17
|
+
// the invoker's own authorisation, budget and per-principal identity (`opts.principal`) in force.
|
|
18
|
+
// A kind never gets a handle on the runtime, only the invoker. Everything else — waves, templates,
|
|
19
|
+
// params, reshaping, output checks, caching, finding vocabulary — is the kind-independent part and
|
|
20
|
+
// stays in `executeProvider`.
|
|
21
|
+
//
|
|
22
|
+
// THE PIN IS THE CONTRACT. Every step carries `toolSchemaHash` (required, non-empty): the kind's
|
|
23
|
+
// fingerprint of the target's input contract, taken when the step was authored. `invoke` MUST
|
|
24
|
+
// compare it with the target's current pin and, on a mismatch, throw
|
|
25
|
+
// `TypedFailure("schemaDrift", { observedHash })` BEFORE running the target. That is what makes
|
|
26
|
+
// drift detectable, repairable (`remapOperation` re-pins from `observedHash`) and what lets
|
|
27
|
+
// `inputDrift: "report-and-proceed"` retry once with the observed pin. A kind that cannot say what
|
|
28
|
+
// its target's contract currently is must not be registered: there is no "unpinned" kind. (MCP
|
|
29
|
+
// delegates the comparison to `mcp.invoke`, which verifies it per call; `loomEffectProvider` keeps
|
|
30
|
+
// the pins in a host-declared table.) `checkDataProviderConformance` asserts exactly this.
|
|
31
|
+
|
|
32
|
+
/** One call a kind is asked to make. */
|
|
33
|
+
export interface DataProviderCall {
|
|
34
|
+
readonly stepId: string;
|
|
35
|
+
/** The step's `toolName`: the target's id WITHIN this kind. */
|
|
36
|
+
readonly target: string;
|
|
37
|
+
/** The step's arguments, after params, time tokens, templates and the host's hook. */
|
|
38
|
+
readonly args: Record<string, unknown>;
|
|
39
|
+
/** The step's `toolSchemaHash`, or the observed pin on the one drift retry. */
|
|
40
|
+
readonly expectedPin: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface DataProvider {
|
|
44
|
+
/** The kind id steps name. Lower-case/digits/`-`; never `"mcp"` unless it IS the MCP provider. */
|
|
45
|
+
readonly kind: string;
|
|
46
|
+
/**
|
|
47
|
+
* Run the call and return the raw output. Contract: throw `TypedFailure("schemaDrift",
|
|
48
|
+
* { observedHash })` when `expectedPin` is not the target's current pin, before running it; any
|
|
49
|
+
* other `TypedFailure` becomes a `tool-<channel>` finding, anything else `tool-failed`. All
|
|
50
|
+
* calls MUST go through `invoker` with `opts` (it carries the signal and the principal).
|
|
51
|
+
*/
|
|
52
|
+
invoke(
|
|
53
|
+
call: DataProviderCall,
|
|
54
|
+
invoker: TransientInvoker,
|
|
55
|
+
opts: TransientInvokeOptions
|
|
56
|
+
): Promise<unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The kinds a host composed, by id. Immutable once built. */
|
|
60
|
+
export interface DataProviderRegistry {
|
|
61
|
+
has(kind: string): boolean;
|
|
62
|
+
get(kind: string): DataProvider | undefined;
|
|
63
|
+
/** Registered kind ids, sorted. */
|
|
64
|
+
kinds(): readonly string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build a registry. Refuses a malformed or duplicate kind id: two providers for one kind would
|
|
69
|
+
* make what a stored experience means depend on registration order.
|
|
70
|
+
*/
|
|
71
|
+
export function defineDataProviders(providers: readonly DataProvider[]): DataProviderRegistry {
|
|
72
|
+
const byKind = new Map<string, DataProvider>();
|
|
73
|
+
for (const p of providers) {
|
|
74
|
+
if (typeof p.kind !== "string" || !STEP_KIND_PATTERN.test(p.kind))
|
|
75
|
+
throw new TypeError(`defineDataProviders: "${String(p.kind)}" is not a valid kind id`);
|
|
76
|
+
if (byKind.has(p.kind))
|
|
77
|
+
throw new TypeError(`defineDataProviders: kind "${p.kind}" is registered twice`);
|
|
78
|
+
byKind.set(p.kind, p);
|
|
79
|
+
}
|
|
80
|
+
const sorted = Object.freeze([...byKind.keys()].sort());
|
|
81
|
+
return {
|
|
82
|
+
has: (kind) => byKind.has(kind),
|
|
83
|
+
get: (kind) => byKind.get(kind),
|
|
84
|
+
kinds: () => sorted,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The MCP kind: `mcp.invoke` with the pin as `expectedSchemaHash` (the plugin verifies it per call). */
|
|
89
|
+
export const mcpProvider: DataProvider = Object.freeze<DataProvider>({
|
|
90
|
+
kind: DEFAULT_STEP_KIND,
|
|
91
|
+
invoke: (call, invoker, opts) =>
|
|
92
|
+
invoker.invokeTransient(
|
|
93
|
+
MCP_INVOKE_TOOL_ID,
|
|
94
|
+
{ toolName: call.target, args: call.args, expectedSchemaHash: call.expectedPin },
|
|
95
|
+
opts
|
|
96
|
+
),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** What `executeProvider` uses when the host registers nothing: MCP only — the behaviour before kinds. */
|
|
100
|
+
export const DEFAULT_DATA_PROVIDERS: DataProviderRegistry = defineDataProviders([mcpProvider]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Assert the one rule every kind must keep: a wrong pin is `schemaDrift` carrying the observed
|
|
104
|
+
* pin, and the right pin runs the target. Run it in a kind's own test suite against a target the
|
|
105
|
+
* kind serves (`target`, with its current `pin`) and a recording invoker. Returns a list of
|
|
106
|
+
* violations (empty = conformant); never throws for a violation. A wrong pin must ALSO not reach
|
|
107
|
+
* the target (a direct call to `target` through the invoker is counted). This is a TEST HELPER: `defineDataProviders`
|
|
108
|
+
* cannot enforce the rule at registration, so a kind that skips this suite is unchecked.
|
|
109
|
+
*/
|
|
110
|
+
export async function checkDataProviderConformance(
|
|
111
|
+
provider: DataProvider,
|
|
112
|
+
fixture: { target: string; pin: string; invoker: TransientInvoker }
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const problems: string[] = [];
|
|
115
|
+
let reached = 0;
|
|
116
|
+
const counting: TransientInvoker = {
|
|
117
|
+
invokeTransient: (...a) => {
|
|
118
|
+
// Only a call to the target's OWN id counts: the MCP kind reaches `mcp.invoke`, whose pin
|
|
119
|
+
// check lives inside that effect (and is MCP's own conformance, not this kit's).
|
|
120
|
+
if (a[0] === fixture.target) reached++;
|
|
121
|
+
return fixture.invoker.invokeTransient(...a);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const base = { stepId: "conformance", target: fixture.target, args: {} };
|
|
125
|
+
try {
|
|
126
|
+
await provider.invoke({ ...base, expectedPin: `${fixture.pin}-stale` }, counting, {});
|
|
127
|
+
problems.push("a stale pin did not fail");
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (!(e instanceof TypedFailure && e.channel === "schemaDrift"))
|
|
130
|
+
problems.push("a stale pin failed, but not as TypedFailure(schemaDrift)");
|
|
131
|
+
else if ((e.payload as { observedHash?: string } | undefined)?.observedHash !== fixture.pin)
|
|
132
|
+
problems.push("schemaDrift did not carry the target's current pin as observedHash");
|
|
133
|
+
}
|
|
134
|
+
if (reached > 0) problems.push("a stale pin reached the target before failing");
|
|
135
|
+
try {
|
|
136
|
+
await provider.invoke({ ...base, expectedPin: fixture.pin }, fixture.invoker, {});
|
|
137
|
+
} catch (e) {
|
|
138
|
+
problems.push(`the current pin did not run the target: ${e instanceof Error ? e.message : e}`);
|
|
139
|
+
}
|
|
140
|
+
return problems;
|
|
141
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export {
|
|
2
|
+
HYDRATION_PROJECTION_ID,
|
|
3
|
+
HYDRATION_PROJECTION_VERSION,
|
|
4
|
+
HYDRATION_SET_EVENT,
|
|
5
|
+
HydrationAudienceMismatchError,
|
|
6
|
+
HydrationScopeError,
|
|
7
|
+
HydrationStore,
|
|
8
|
+
hydrationProjection,
|
|
9
|
+
isReservedHandle,
|
|
10
|
+
} from "./projection";
|
|
11
|
+
export type {
|
|
12
|
+
HandleSlice,
|
|
13
|
+
HandleState,
|
|
14
|
+
HandleStatus,
|
|
15
|
+
HydrationScope,
|
|
16
|
+
HydrationState,
|
|
17
|
+
} from "./projection";
|
|
18
|
+
export type { TransientInvoker } from "./invoker";
|
|
19
|
+
export { limitInvoker, memoInvoker, signalInvoker } from "./invokers";
|
|
20
|
+
export { defineReshapers, jsonPathSelect, outputShapeHashOf } from "./reshape";
|
|
21
|
+
export type { ReshapeFailureKind, ReshapeResult, Reshaper, ReshaperRegistry } from "./reshape";
|
|
22
|
+
export { DEFAULT_TIME_RANGE, checkParams } from "./params";
|
|
23
|
+
export { ContentRefConfigError } from "./contentRefs";
|
|
24
|
+
export type { ContentRefParamGate } from "./contentRefs";
|
|
25
|
+
export type {
|
|
26
|
+
ArgumentHook,
|
|
27
|
+
ArgumentHookContext,
|
|
28
|
+
LoadParams,
|
|
29
|
+
} from "./params";
|
|
30
|
+
export {
|
|
31
|
+
DEFAULT_DATA_PROVIDERS,
|
|
32
|
+
checkDataProviderConformance,
|
|
33
|
+
defineDataProviders,
|
|
34
|
+
mcpProvider,
|
|
35
|
+
} from "./dataProvider";
|
|
36
|
+
export type { DataProvider, DataProviderCall, DataProviderRegistry } from "./dataProvider";
|
|
37
|
+
export {
|
|
38
|
+
LOOM_EFFECT_KIND,
|
|
39
|
+
TARGET_FAILED,
|
|
40
|
+
TARGET_UNREGISTERED,
|
|
41
|
+
effectPin,
|
|
42
|
+
loomEffectProvider,
|
|
43
|
+
} from "./loomEffectProvider";
|
|
44
|
+
export type {
|
|
45
|
+
EffectEntry,
|
|
46
|
+
EffectInputSchema,
|
|
47
|
+
LoomEffectProviderOptions,
|
|
48
|
+
} from "./loomEffectProvider";
|
|
49
|
+
export { executeProvider } from "./provider";
|
|
50
|
+
export { stepReferences, terminalSteps, topoWaves } from "./plan";
|
|
51
|
+
export type { DriftRecord, ExecuteOptions, InputDriftMode, ProviderOutcome } from "./provider";
|
|
52
|
+
export { DEFAULT_CACHE_MAX_ENTRIES, HydrationCache } from "./cache";
|
|
53
|
+
export type { CacheVariant, HydrationCacheOptions } from "./cache";
|
|
54
|
+
export type { HandleLoadResult } from "./scheduler";
|
|
55
|
+
export { HydrationController } from "./controller";
|
|
56
|
+
export type { ControllerOptions, DriftReport } from "./controller";
|
|
57
|
+
export {
|
|
58
|
+
DEFAULT_MAX_BATCH,
|
|
59
|
+
DEFAULT_MAX_CONCURRENT_CALLS,
|
|
60
|
+
SplitHydrationController,
|
|
61
|
+
createServeSurface,
|
|
62
|
+
handleInventory,
|
|
63
|
+
publicFinding,
|
|
64
|
+
serveHandle,
|
|
65
|
+
serveHandles,
|
|
66
|
+
} from "./split/index";
|
|
67
|
+
export type {
|
|
68
|
+
HandleInventory,
|
|
69
|
+
HandleLoadRequest,
|
|
70
|
+
HandleLoader,
|
|
71
|
+
HandleRef,
|
|
72
|
+
InventoryOptions,
|
|
73
|
+
RevisionFn,
|
|
74
|
+
ServeOptions,
|
|
75
|
+
ServeSurface,
|
|
76
|
+
SplitControllerOptions,
|
|
77
|
+
} from "./split/index";
|
|
78
|
+
export { SCHEMA_REPAIRED, remapOperation } from "./repair";
|
|
79
|
+
export type { SchemaRepairedPayload } from "./repair";
|
|
80
|
+
export { connectConversation, publishChat } from "./chatSource";
|
|
81
|
+
export type {
|
|
82
|
+
ConversationConnection,
|
|
83
|
+
ConversationConnectionOptions,
|
|
84
|
+
EventSourceLike,
|
|
85
|
+
FetchResponseLike,
|
|
86
|
+
} from "./chatSource";
|
package/src/invoker.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one thing hydration needs from loom's runtime: a one-shot, non-durable effect call.
|
|
5
|
+
* `EffectRuntime` satisfies this structurally (ADR 0066 `invokeTransient`); tests use a fake.
|
|
6
|
+
* Keeping it an interface is what makes the read path provably log-free — there is no `ctx`.
|
|
7
|
+
*/
|
|
8
|
+
export interface TransientInvoker {
|
|
9
|
+
invokeTransient<TIn = unknown, TOut = unknown>(
|
|
10
|
+
toolId: string,
|
|
11
|
+
input: TIn,
|
|
12
|
+
opts?: TransientInvokeOptions
|
|
13
|
+
): Promise<TOut>;
|
|
14
|
+
}
|
package/src/invokers.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { canonicalJson } from "@loomwork/core";
|
|
2
|
+
import type { TransientInvokeOptions } from "@loomwork/effect-runtime";
|
|
3
|
+
import { MCP_INVOKE_TOOL_ID } from "@loomwork/plugin-mcp";
|
|
4
|
+
import type { TransientInvoker } from "./invoker";
|
|
5
|
+
|
|
6
|
+
// ── Invoker wrappers: a call cap, an owner's lifetime signal, an in-pass step memo ─
|
|
7
|
+
// All are TransientInvokers wrapping a TransientInvoker, so they compose with each other and
|
|
8
|
+
// with loom's EffectRuntime without either knowing the other exists. The in-process controller
|
|
9
|
+
// builds ONE `limitInvoker` (the cap is controller-wide: it bounds every call it makes), ONE
|
|
10
|
+
// `signalInvoker` over it bound to its own lifetime (so `dispose` cancels every upstream call at
|
|
11
|
+
// once), and one `memoInvoker` PER PASS in front of both (a memo hit takes no slot). The split
|
|
12
|
+
// server (`serveHandles`, split.ts) composes a memo over a cap the same way, once per batch.
|
|
13
|
+
// None ever returns a promise it did not get from the inner invoker except to wait for a slot,
|
|
14
|
+
// so they add no settle latency on a free slot.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The rejection for a call abandoned because its signal aborted. Named `AbortError` (the
|
|
18
|
+
* platform's own name for a cancelled operation) so a consumer classifies it by `name` — a
|
|
19
|
+
* planner's decider must propagate a cancellation, never retry it or record it as a failed
|
|
20
|
+
* plan. A plain `Error("aborted")` is indistinguishable from a provider failure. Message kept.
|
|
21
|
+
*/
|
|
22
|
+
function abortError(): Error {
|
|
23
|
+
const e = new Error("aborted");
|
|
24
|
+
e.name = "AbortError";
|
|
25
|
+
return e;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* At most `max` calls in flight through this invoker, across every caller. A queued call whose
|
|
30
|
+
* signal aborts leaves the queue and rejects `"aborted"` without ever reaching `inner`. FIFO: a
|
|
31
|
+
* finished call hands its slot straight to the oldest waiter.
|
|
32
|
+
*/
|
|
33
|
+
export function limitInvoker(inner: TransientInvoker, max: number): TransientInvoker {
|
|
34
|
+
if (!Number.isInteger(max) || max < 1)
|
|
35
|
+
throw new RangeError(`limitInvoker: the cap must be a positive integer, got ${String(max)}`);
|
|
36
|
+
let active = 0;
|
|
37
|
+
// Insertion-ordered: the first entry is the oldest waiter; add/delete are O(1).
|
|
38
|
+
const waiting = new Set<() => void>();
|
|
39
|
+
const release = (): void => {
|
|
40
|
+
const next = waiting.values().next();
|
|
41
|
+
if (next.done === true) {
|
|
42
|
+
active -= 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
waiting.delete(next.value);
|
|
46
|
+
next.value(); // the slot passes to the waiter: `active` is unchanged
|
|
47
|
+
};
|
|
48
|
+
const acquire = (signal: AbortSignal | undefined): Promise<void> | undefined => {
|
|
49
|
+
// Checked before capacity: an already-aborted caller must never take a slot (or reach
|
|
50
|
+
// `inner`) just because one happened to be free — that slot belongs to the next live caller.
|
|
51
|
+
if (signal?.aborted === true) return Promise.reject(abortError());
|
|
52
|
+
if (active < max) {
|
|
53
|
+
active += 1;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return new Promise<void>((resolve, reject) => {
|
|
57
|
+
const onAbort = (): void => {
|
|
58
|
+
waiting.delete(grant);
|
|
59
|
+
reject(abortError());
|
|
60
|
+
};
|
|
61
|
+
const grant = (): void => {
|
|
62
|
+
signal?.removeEventListener("abort", onAbort);
|
|
63
|
+
resolve();
|
|
64
|
+
};
|
|
65
|
+
waiting.add(grant);
|
|
66
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
const run = <TOut>(call: () => Promise<TOut>): Promise<TOut> => {
|
|
70
|
+
let p: Promise<TOut>;
|
|
71
|
+
try {
|
|
72
|
+
p = call();
|
|
73
|
+
} catch (e) {
|
|
74
|
+
release();
|
|
75
|
+
return Promise.reject(e);
|
|
76
|
+
}
|
|
77
|
+
p.then(release, release);
|
|
78
|
+
return p;
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
invokeTransient<TIn = unknown, TOut = unknown>(
|
|
82
|
+
toolId: string,
|
|
83
|
+
input: TIn,
|
|
84
|
+
opts?: TransientInvokeOptions
|
|
85
|
+
): Promise<TOut> {
|
|
86
|
+
const call = (): Promise<TOut> => inner.invokeTransient<TIn, TOut>(toolId, input, opts);
|
|
87
|
+
const slot = acquire(opts?.signal);
|
|
88
|
+
return slot === undefined ? run(call) : slot.then(() => run(call));
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `call()`, with a synchronous throw turned into the rejected promise the contract promises. */
|
|
94
|
+
function callSafely<T>(call: () => Promise<T>): Promise<T> {
|
|
95
|
+
try {
|
|
96
|
+
return call();
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return Promise.reject(e);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every call through this invoker is ALSO cancelled, synchronously, when `signal` aborts — the
|
|
104
|
+
* owner's lifetime (a controller's `dispose`), independent of any caller's own signal, which still
|
|
105
|
+
* reaches `inner` too. Once `signal` has aborted, new calls reject `"aborted"` without reaching
|
|
106
|
+
* `inner`. Leak-free on a long-lived `signal`: a call holds its listener only until it settles.
|
|
107
|
+
*/
|
|
108
|
+
export function signalInvoker(inner: TransientInvoker, signal: AbortSignal): TransientInvoker {
|
|
109
|
+
return {
|
|
110
|
+
invokeTransient<TIn = unknown, TOut = unknown>(
|
|
111
|
+
toolId: string,
|
|
112
|
+
input: TIn,
|
|
113
|
+
opts?: TransientInvokeOptions
|
|
114
|
+
): Promise<TOut> {
|
|
115
|
+
const own = opts?.signal;
|
|
116
|
+
if (signal.aborted || own?.aborted === true) return Promise.reject(abortError());
|
|
117
|
+
const ac = new AbortController();
|
|
118
|
+
const abort = (): void => ac.abort();
|
|
119
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
120
|
+
own?.addEventListener("abort", abort, { once: true });
|
|
121
|
+
const release = (): void => {
|
|
122
|
+
signal.removeEventListener("abort", abort);
|
|
123
|
+
own?.removeEventListener("abort", abort);
|
|
124
|
+
};
|
|
125
|
+
const p = callSafely(() =>
|
|
126
|
+
inner.invokeTransient<TIn, TOut>(toolId, input, { ...opts, signal: ac.signal })
|
|
127
|
+
);
|
|
128
|
+
p.then(release, release);
|
|
129
|
+
return p;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface SharedCall {
|
|
135
|
+
readonly promise: Promise<unknown>;
|
|
136
|
+
/** Cancels the ONE upstream call `inner` is actually making — aborted only once every live
|
|
137
|
+
* caller below has aborted; never by any single caller's own signal. */
|
|
138
|
+
readonly controller: AbortController;
|
|
139
|
+
/**
|
|
140
|
+
* How many callers (leader included) have not yet aborted. Never decremented on a normal
|
|
141
|
+
* settle — once the shared call settles, `live` stops mattering: a settled entry (see
|
|
142
|
+
* `settled` below) is kept for the rest of the pass no matter what `live` says. This field
|
|
143
|
+
* exists solely to answer one question — has the LAST live caller just aborted? — which is
|
|
144
|
+
* the only time the still-in-flight upstream call should be cancelled.
|
|
145
|
+
*/
|
|
146
|
+
live: number;
|
|
147
|
+
/**
|
|
148
|
+
* Set once, from a handler attached at entry-creation time (so it runs before any caller
|
|
149
|
+
* added later), the instant `promise` settles — success or failure alike. Guards
|
|
150
|
+
* `onCallerAbort`: an abort landing after the upstream has already settled, but before that
|
|
151
|
+
* settlement handler has actually RUN (a real race — `resolve()`/`reject()` marks a promise
|
|
152
|
+
* settled synchronously, while every `.then` callback, this one included, only runs on a later
|
|
153
|
+
* microtask), must never cancel or evict a call whose result already exists.
|
|
154
|
+
*/
|
|
155
|
+
settled: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The shape `canonicalJson` actually understands — null/string/finite-number/boolean, and arrays
|
|
160
|
+
* or plain objects (`Object.prototype` or `null` as the prototype) built from those, recursively.
|
|
161
|
+
* `canonicalJson` does not THROW on a `Date`, `Map`, `Set` or a class instance — it silently reads
|
|
162
|
+
* `Object.keys()` on it, which for most of those is `[]`, so two calls with DIFFERENT such
|
|
163
|
+
* arguments would canonicalize to the same `"{}"` and collide on one memo key. Reject those before
|
|
164
|
+
* a key is ever computed, not after: `memoInvoker` must never be the reason a value collapses.
|
|
165
|
+
*
|
|
166
|
+
* `seen` makes this cycle-safe: a hostile or accidental circular reference is walked at most once
|
|
167
|
+
* per object (the second visit reports "not plain" rather than recursing forever), so a cyclic
|
|
168
|
+
* argument bypasses the memo instead of throwing `RangeError: Maximum call stack size exceeded`
|
|
169
|
+
* out of `invokeTransient`. Sharing one `seen` set across a whole call's plainness check is
|
|
170
|
+
* deliberately conservative: a repeated (non-cyclic) reference is also flagged "not plain" and
|
|
171
|
+
* bypasses sharing rather than being memoized — safe, since bypass just means no caching, never a
|
|
172
|
+
* wrong answer.
|
|
173
|
+
*/
|
|
174
|
+
function isPlainJsonValue(value: unknown, seen: WeakSet<object> = new WeakSet()): boolean {
|
|
175
|
+
if (value === null || value === undefined) return true;
|
|
176
|
+
const t = typeof value;
|
|
177
|
+
if (t === "string" || t === "boolean") return true;
|
|
178
|
+
if (t === "number") return Number.isFinite(value as number);
|
|
179
|
+
if (t !== "object") return false; // function, bigint, symbol
|
|
180
|
+
const obj = value as object;
|
|
181
|
+
if (seen.has(obj)) return false;
|
|
182
|
+
seen.add(obj);
|
|
183
|
+
if (Array.isArray(value)) return value.every((v) => isPlainJsonValue(v, seen));
|
|
184
|
+
const proto = Object.getPrototypeOf(obj);
|
|
185
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
186
|
+
return Object.values(value as Record<string, unknown>).every((v) => isPlainJsonValue(v, seen));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One call per identical MCP step for this invoker's lifetime (a PASS): same tool, same resolved
|
|
191
|
+
* arguments, same schema pin, same call options — the canonical JSON of `[principal, every other
|
|
192
|
+
* option except signal, mcp.invoke's input]`, so key order does not matter. A joiner is
|
|
193
|
+
* served straight from the shared promise — it never calls `inner` itself, so it never passes
|
|
194
|
+
* through whatever per-call authorizer `inner` enforces. That is safe ONLY because every
|
|
195
|
+
* authorization input is part of the key: the principal, and also the stamped execution identity
|
|
196
|
+
* (`as` — an authorizer may grant by workflow), the `label` (which names the transient
|
|
197
|
+
* execution when `as` is absent) and anything else in the options. Every joiner
|
|
198
|
+
* sharing one entry was, by construction, asking with the exact inputs the leader's own (sole)
|
|
199
|
+
* call to `inner` was authorized for; an option that is not plain JSON passes through, uncached. Settled results stay (a step in a later wave is still
|
|
200
|
+
* served) and failures are shared. Other effects (a sandbox reshape) always run, uncached.
|
|
201
|
+
*
|
|
202
|
+
* Cancellation is ref-counted, not leader-owned: `inner` is called once, through an internal
|
|
203
|
+
* `AbortController` this function owns — never a caller's own signal directly — so any ONE
|
|
204
|
+
* caller's abort only ever settles that caller's OWN returned promise (a race against the shared
|
|
205
|
+
* promise). The upstream call itself is cancelled, and the entry forgotten, only once every live
|
|
206
|
+
* caller has aborted AND the call had not already settled; a caller that arrives after a real
|
|
207
|
+
* cancellation finds no entry and starts a fresh call.
|
|
208
|
+
*/
|
|
209
|
+
export function memoInvoker(inner: TransientInvoker): TransientInvoker {
|
|
210
|
+
const memo = new Map<string, SharedCall>();
|
|
211
|
+
|
|
212
|
+
const onCallerAbort = (key: string, hit: SharedCall): void => {
|
|
213
|
+
hit.live -= 1;
|
|
214
|
+
if (hit.live > 0) return;
|
|
215
|
+
// Deferred one microtask: `hit.settled` is set by a handler attached at entry-creation time,
|
|
216
|
+
// which — because it was attached first — runs before this check for any settlement the memo
|
|
217
|
+
// can observe, even when the abort lands in the very same synchronous turn as `hit.promise`
|
|
218
|
+
// settling. Checking `hit.settled` synchronously right here would miss exactly that race.
|
|
219
|
+
// "The memo can observe" is `hit.promise` — `inner`'s promise — not the raw upstream: behind
|
|
220
|
+
// a queued `limitInvoker` (or any wrapper that settles a few hops after its own inner call),
|
|
221
|
+
// an all-abort landing after the RAW upstream settled but before `hit.promise` does still
|
|
222
|
+
// cancels and evicts, so a later caller re-fetches. A wasted call, never a wrong answer.
|
|
223
|
+
//
|
|
224
|
+
// Both conditions below are RE-READ at that later point, not captured now, because either can
|
|
225
|
+
// change in the gap between "live just dropped to 0" and "the microtask actually runs": a
|
|
226
|
+
// fresh caller with no signal of its own can join in the very same synchronous turn (bumping
|
|
227
|
+
// `live` back above 0 before this ever executes), and the upstream can settle in that same
|
|
228
|
+
// window (flipping `settled` true). A snapshot taken at decrement time would race both ways —
|
|
229
|
+
// this is why the check happens here, once, right before the one place that acts on it.
|
|
230
|
+
//
|
|
231
|
+
// Invariant: the controller is aborted, and the entry deleted, AT MOST ONCE per entry — and
|
|
232
|
+
// only when, at the moment this specific check runs, `settled` is still false AND `live` is
|
|
233
|
+
// still <= 0 (no one re-joined). Either condition failing means someone still needs this call
|
|
234
|
+
// to keep going (or already has its answer), so it is left alone.
|
|
235
|
+
queueMicrotask(() => {
|
|
236
|
+
if (hit.settled || hit.live > 0) return;
|
|
237
|
+
// Forget the entry BEFORE aborting: `abort()` runs `inner`'s listeners synchronously, and a
|
|
238
|
+
// caller re-entering with the same key from one of them must start a fresh call, not join
|
|
239
|
+
// this dying one.
|
|
240
|
+
if (memo.get(key) === hit) memo.delete(key);
|
|
241
|
+
hit.controller.abort();
|
|
242
|
+
});
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const join = <TOut>(
|
|
246
|
+
key: string,
|
|
247
|
+
hit: SharedCall,
|
|
248
|
+
signal: AbortSignal | undefined
|
|
249
|
+
): Promise<TOut> => {
|
|
250
|
+
if (signal?.aborted === true) {
|
|
251
|
+
onCallerAbort(key, hit);
|
|
252
|
+
return Promise.reject(abortError());
|
|
253
|
+
}
|
|
254
|
+
return new Promise<TOut>((resolve, reject) => {
|
|
255
|
+
let settled = false;
|
|
256
|
+
const onAbort = (): void => {
|
|
257
|
+
if (settled) return;
|
|
258
|
+
settled = true;
|
|
259
|
+
onCallerAbort(key, hit);
|
|
260
|
+
reject(abortError());
|
|
261
|
+
};
|
|
262
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
263
|
+
hit.promise.then(
|
|
264
|
+
(v) => {
|
|
265
|
+
if (settled) return;
|
|
266
|
+
settled = true;
|
|
267
|
+
signal?.removeEventListener("abort", onAbort);
|
|
268
|
+
resolve(v as TOut);
|
|
269
|
+
},
|
|
270
|
+
(e: unknown) => {
|
|
271
|
+
if (settled) return;
|
|
272
|
+
settled = true;
|
|
273
|
+
signal?.removeEventListener("abort", onAbort);
|
|
274
|
+
reject(e);
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
invokeTransient<TIn = unknown, TOut = unknown>(
|
|
282
|
+
toolId: string,
|
|
283
|
+
input: TIn,
|
|
284
|
+
opts?: TransientInvokeOptions
|
|
285
|
+
): Promise<TOut> {
|
|
286
|
+
// Every pass-through is guarded like the leader's call: a synchronous throw from `inner`
|
|
287
|
+
// must come back as a rejected promise, never escape `invokeTransient`.
|
|
288
|
+
const passThrough = (): Promise<TOut> =>
|
|
289
|
+
callSafely(() => inner.invokeTransient<TIn, TOut>(toolId, input, opts));
|
|
290
|
+
if (toolId !== MCP_INVOKE_TOOL_ID) return passThrough();
|
|
291
|
+
// Everything in `opts` except the one field that cannot change the answer (`signal`) is
|
|
292
|
+
// part of the key: the principal, the stamped identity (`as`, which an authorizer may key
|
|
293
|
+
// grants on), the `label` (without `as`, the transient executionId is `transient:<label>`,
|
|
294
|
+
// and an authorizer may grant per execution), the budget snapshot, and any option added
|
|
295
|
+
// later. Keying on the remainder rather than a list of known fields is what keeps a joiner
|
|
296
|
+
// from being served a result authorized for a different input this function has never
|
|
297
|
+
// heard of.
|
|
298
|
+
const { signal: _signal, principal = null, ...rest } = opts ?? {};
|
|
299
|
+
let key: string;
|
|
300
|
+
try {
|
|
301
|
+
const seen = new WeakSet<object>();
|
|
302
|
+
if (
|
|
303
|
+
!isPlainJsonValue(principal, seen) ||
|
|
304
|
+
!isPlainJsonValue(rest, seen) ||
|
|
305
|
+
!isPlainJsonValue(input, seen)
|
|
306
|
+
)
|
|
307
|
+
return passThrough();
|
|
308
|
+
key = canonicalJson([principal, rest, input]);
|
|
309
|
+
} catch {
|
|
310
|
+
return passThrough();
|
|
311
|
+
}
|
|
312
|
+
const hit = memo.get(key);
|
|
313
|
+
if (hit !== undefined) {
|
|
314
|
+
hit.live += 1;
|
|
315
|
+
return join<TOut>(key, hit, opts?.signal);
|
|
316
|
+
}
|
|
317
|
+
// Mirrors limitInvoker's fast path: an already-aborted caller must never become the leader
|
|
318
|
+
// — that would call `inner`, create an entry nobody asked to keep, and (since nothing here
|
|
319
|
+
// would yet be subscribed to the resulting promise) risk an unhandled rejection.
|
|
320
|
+
if (opts?.signal?.aborted === true) return Promise.reject(abortError());
|
|
321
|
+
const controller = new AbortController();
|
|
322
|
+
let promise: Promise<TOut>;
|
|
323
|
+
try {
|
|
324
|
+
// `inner.invokeTransient` is typed to return a Promise, but a misbehaving implementation
|
|
325
|
+
// can still throw synchronously; the interface contract (and every caller of THIS
|
|
326
|
+
// function) requires a rejected promise instead, never a thrown exception. No entry is
|
|
327
|
+
// created for a call that never actually started.
|
|
328
|
+
promise = inner.invokeTransient<TIn, TOut>(toolId, input, {
|
|
329
|
+
...opts,
|
|
330
|
+
signal: controller.signal,
|
|
331
|
+
});
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return Promise.reject(e);
|
|
334
|
+
}
|
|
335
|
+
const entry: SharedCall = { promise, controller, live: 1, settled: false };
|
|
336
|
+
const onSettle = (): void => {
|
|
337
|
+
entry.settled = true;
|
|
338
|
+
};
|
|
339
|
+
// Attached at entry-creation time — before any joiner's own `.then` — so `entry.settled`
|
|
340
|
+
// is set before `onCallerAbort`'s deferred check can run (see there). Also the
|
|
341
|
+
// backstop: attaching a rejection handler here means NO path can ever leave `promise`
|
|
342
|
+
// unhandled, even if a future caller subscribes late or never at all.
|
|
343
|
+
promise.then(onSettle, onSettle);
|
|
344
|
+
memo.set(key, entry);
|
|
345
|
+
return join<TOut>(key, entry, opts?.signal);
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|