@zackbart/connecta 0.24.4 → 0.25.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/AGENTS.md +40 -10
- package/CHANGELOG.md +253 -0
- package/dist/advertised-schema.js +53 -0
- package/dist/auth/downstream-oauth.js +490 -106
- package/dist/call-admission.js +139 -113
- package/dist/catalog-service.js +141 -105
- package/dist/catalog.js +258 -33
- package/dist/concurrency.js +24 -23
- package/dist/connector-scope.js +6 -37
- package/dist/connectors/api.d.ts +2 -2
- package/dist/connectors/remote-mcp.d.ts +6 -5
- package/dist/connectors/remote-mcp.js +317 -241
- package/dist/credential-contract.d.ts +9 -0
- package/dist/credential-rules.js +2 -2
- package/dist/credentials.d.ts +6 -1
- package/dist/credentials.js +45 -3
- package/dist/execute.js +363 -257
- package/dist/executor-admission.js +67 -50
- package/dist/executors/quickjs-runtime.js +4 -2
- package/dist/executors/quickjs.js +251 -210
- package/dist/fix-prompt.js +37 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +51 -20
- package/dist/invocation.js +324 -285
- package/dist/meta-tools.js +206 -92
- package/dist/oauth-callback-outcome.js +94 -0
- package/dist/oauth-sealing.js +29 -0
- package/dist/operator-ui/fix-prompts.js +87 -0
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +46 -1
- package/dist/operator-ui/setup-commands.js +53 -0
- package/dist/operator-ui/view.js +38 -2
- package/dist/program-source.js +205 -0
- package/dist/providers/notion.js +2 -2
- package/dist/providers/revenuecat.js +32 -5
- package/dist/providers/stripe.d.ts +3 -2
- package/dist/providers/vercel.js +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.js +305 -260
- package/dist/routes/activity.js +120 -137
- package/dist/routes/credentials.js +174 -163
- package/dist/routes/mcp.js +129 -70
- package/dist/routes/oauth-management.js +60 -71
- package/dist/routes/oauth.js +44 -16
- package/dist/routes/operator.js +82 -0
- package/dist/routes/shared.d.ts +2 -0
- package/dist/routes/ui-data.js +210 -0
- package/dist/routes/ui.js +64 -32
- package/dist/runtime/admission.js +43 -0
- package/dist/runtime/call-admission.js +67 -0
- package/dist/runtime/connector-scope.js +72 -0
- package/dist/runtime/run.js +174 -0
- package/dist/runtime/services.js +130 -0
- package/dist/runtime/storage.js +66 -0
- package/dist/server.js +111 -89
- package/dist/skills.js +1 -1
- package/dist/storage/file.js +39 -2
- package/dist/storage/memory.js +28 -16
- package/dist/timeout.js +17 -47
- package/dist/types.d.ts +27 -7
- package/dist/ui.d.ts +16 -2
- package/dist/ui.js +50 -166
- package/dist/url-safety.js +157 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +184 -10
- package/documentation/auth.md +80 -0
- package/documentation/code-mode.md +69 -24
- package/documentation/meta-tools.md +164 -27
- package/ethos.md +44 -47
- package/examples/worker/README.md +51 -5
- package/examples/worker/src/d1-storage.ts +116 -0
- package/examples/worker/src/index.ts +6 -6
- package/examples/worker/wrangler.jsonc +16 -4
- package/package.json +11 -3
- package/templates/node/package.json +1 -1
- package/dist/auth/downstream-oauth.d.ts +0 -200
- package/dist/catalog-fingerprint.d.ts +0 -19
- package/dist/catalog-limits.d.ts +0 -9
- package/dist/catalog-service.d.ts +0 -263
- package/dist/catalog.d.ts +0 -87
- package/dist/concurrency.d.ts +0 -3
- package/dist/connectors/guarded-fetch.d.ts +0 -96
- package/dist/credential-rules.d.ts +0 -85
- package/dist/execute.d.ts +0 -154
- package/dist/executor-result.d.ts +0 -10
- package/dist/executors/quickjs-child.d.ts +0 -1
- package/dist/executors/quickjs-protocol.d.ts +0 -67
- package/dist/favicon.d.ts +0 -1
- package/dist/identity.d.ts +0 -4
- package/dist/invocation.d.ts +0 -74
- package/dist/mcp-result.d.ts +0 -8
- package/dist/meta-tools.d.ts +0 -129
- package/dist/operator-ui/generated.d.ts +0 -2
- package/dist/operator-ui/view.d.ts +0 -188
- package/dist/result-shapes.d.ts +0 -13
- package/dist/retired-toolkits.d.ts +0 -2
- package/dist/routes/activity.d.ts +0 -2
- package/dist/routes/credentials.d.ts +0 -2
- package/dist/routes/mcp.d.ts +0 -10
- package/dist/routes/oauth-management.d.ts +0 -2
- package/dist/routes/oauth.d.ts +0 -2
- package/dist/routes/ui.d.ts +0 -2
- package/dist/server.d.ts +0 -9
- package/dist/skills.d.ts +0 -44
- package/dist/timeout.d.ts +0 -21
- package/dist/tool-safety.d.ts +0 -9
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The one place an Effect becomes a Promise.
|
|
2
|
+
//
|
|
3
|
+
// Connecta's published surface is Promise-shaped and stays that way; Effect is
|
|
4
|
+
// an implementation detail behind it. Every edge where a fiber starts runs
|
|
5
|
+
// through this module, so the rules for that crossing live in one file instead
|
|
6
|
+
// of being re-derived at each call site — and test/purity.test.ts fails if any
|
|
7
|
+
// other file under src/ calls Effect.run*, runFork, forkDaemon, or
|
|
8
|
+
// ManagedRuntime.make.
|
|
9
|
+
//
|
|
10
|
+
// Two things the stock runners get wrong for this codebase:
|
|
11
|
+
//
|
|
12
|
+
// - The default MixedScheduler yields through setImmediate, which vitest's
|
|
13
|
+
// fake timers replace. A fiber that yields under vi.useFakeTimers() then
|
|
14
|
+
// waits for a timer tick nobody advances. Yielding through a microtask keeps
|
|
15
|
+
// fibers moving under fakes, and Workers never had setImmediate to begin
|
|
16
|
+
// with. The cost is that a fiber never yields to I/O, which is why CPU-heavy
|
|
17
|
+
// work stays out of Effect loops.
|
|
18
|
+
// - Effect.runPromise rejects an interrupted fiber with a generic "All fibers
|
|
19
|
+
// interrupted without error". Callers of withDeadline today see the abort
|
|
20
|
+
// reason the caller chose, and runEdge keeps it that way.
|
|
21
|
+
import { Cause, Context, Duration, Effect, Exit, ManagedRuntime, Scheduler, } from "effect";
|
|
22
|
+
/** Yield to the scheduler through the microtask queue, never a timer. */
|
|
23
|
+
function setMicrotask(task) {
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
if (!cancelled)
|
|
27
|
+
task();
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
cancelled = true;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** The scheduler every edge-run fiber uses; see the header for why. */
|
|
34
|
+
export const microtaskScheduler = new Scheduler.MixedScheduler("async", setMicrotask);
|
|
35
|
+
const runExit = Effect.runPromiseExitWith(Context.make(Scheduler.Scheduler, microtaskScheduler));
|
|
36
|
+
function abortReason(signal) {
|
|
37
|
+
return signal?.reason ?? new DOMException("aborted", "AbortError");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Turn a failed Exit back into the value a Promise caller would have seen.
|
|
41
|
+
*
|
|
42
|
+
* An interrupt caused by the caller's signal wins, as the abort does in
|
|
43
|
+
* withDeadline's race. Otherwise the first typed failure is rethrown as is,
|
|
44
|
+
* then the first defect — never a wrapper, because callers check `instanceof`,
|
|
45
|
+
* `name`, and `message` on connecta's own error classes.
|
|
46
|
+
*/
|
|
47
|
+
function rethrow(cause, signal) {
|
|
48
|
+
const reasons = cause.reasons;
|
|
49
|
+
if (signal?.aborted && reasons.some(Cause.isInterruptReason)) {
|
|
50
|
+
throw abortReason(signal);
|
|
51
|
+
}
|
|
52
|
+
const failure = reasons.find(Cause.isFailReason);
|
|
53
|
+
if (failure)
|
|
54
|
+
throw failure.error;
|
|
55
|
+
const defect = reasons.find(Cause.isDieReason);
|
|
56
|
+
if (defect)
|
|
57
|
+
throw defect.defect;
|
|
58
|
+
throw abortReason(signal);
|
|
59
|
+
}
|
|
60
|
+
const disposedRuntimes = new WeakSet();
|
|
61
|
+
/** Wrap a layer as an EdgeRuntime without building it. */
|
|
62
|
+
export function makeEdgeRuntime(layer) {
|
|
63
|
+
return ManagedRuntime.make(layer);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Release a runtime's layer resources. Idempotent, and never rejects: a
|
|
67
|
+
* finalizer that fails has nobody left to report to.
|
|
68
|
+
*/
|
|
69
|
+
export async function disposeEdgeRuntime(runtime) {
|
|
70
|
+
if (disposedRuntimes.has(runtime))
|
|
71
|
+
return;
|
|
72
|
+
disposedRuntimes.add(runtime);
|
|
73
|
+
// Through runExit rather than runtime.dispose(), whose runner yields on
|
|
74
|
+
// Effect's default scheduler — the one vitest's fake timers freeze.
|
|
75
|
+
await runExit(runtime.disposeEffect);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The effect with the runtime's services provided, building them first when
|
|
79
|
+
* no run has yet. The build runs on the calling fiber's scheduler, so it is
|
|
80
|
+
* the microtask scheduler here, as everything else is.
|
|
81
|
+
*/
|
|
82
|
+
function provideRuntime(effect, runtime) {
|
|
83
|
+
if (disposedRuntimes.has(runtime)) {
|
|
84
|
+
return Effect.die(new Error("This Connecta has been closed."));
|
|
85
|
+
}
|
|
86
|
+
const built = runtime.cachedContext;
|
|
87
|
+
if (built)
|
|
88
|
+
return Effect.provideContext(effect, built);
|
|
89
|
+
return Effect.flatMap(runtime.contextEffect, (context) => Effect.provideContext(effect, context));
|
|
90
|
+
}
|
|
91
|
+
export async function runEdge(effect, options = {}) {
|
|
92
|
+
const exit = await runExit(options.runtime
|
|
93
|
+
? provideRuntime(effect, options.runtime)
|
|
94
|
+
// Without a runtime the overloads admit only an effect that requires
|
|
95
|
+
// nothing, so there is nothing to provide.
|
|
96
|
+
: effect, options.signal ? { signal: options.signal } : undefined);
|
|
97
|
+
if (Exit.isSuccess(exit))
|
|
98
|
+
return exit.value;
|
|
99
|
+
return rethrow(exit.cause, options.signal);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fail with the signal's abort reason once it aborts; never succeed.
|
|
103
|
+
*
|
|
104
|
+
* Race it against work that should stop when the signal does. A signal
|
|
105
|
+
* aborted without a reason fails with an AbortError, as `fetch` would.
|
|
106
|
+
*/
|
|
107
|
+
export function fromSignal(signal) {
|
|
108
|
+
return Effect.callback((resume) => {
|
|
109
|
+
// Remove the listener on every path, firing included. `once` already
|
|
110
|
+
// drops it from a real AbortSignal, but the cleanup below runs only on
|
|
111
|
+
// interruption, and a signal-shaped wrapper sees no removal otherwise.
|
|
112
|
+
const onAbort = () => {
|
|
113
|
+
signal.removeEventListener("abort", onAbort);
|
|
114
|
+
resume(Effect.fail(abortReason(signal)));
|
|
115
|
+
};
|
|
116
|
+
if (signal.aborted) {
|
|
117
|
+
onAbort();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
return Effect.sync(() => signal.removeEventListener("abort", onAbort));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The Effect form of withDeadline (src/timeout.ts), with the same contract.
|
|
126
|
+
*
|
|
127
|
+
* The operation gets a signal of its own. The caller's signal is forwarded to
|
|
128
|
+
* it, and the deadline aborts it with the labelled `timeoutError` *before* the
|
|
129
|
+
* operation's fiber is interrupted, so work that honors the signal sees the
|
|
130
|
+
* same reason the caller does. Either abort fails the effect with that reason:
|
|
131
|
+
* the caller's, or `timeoutError`. The signal is always aborted on exit, as
|
|
132
|
+
* withDeadline's `finally` does.
|
|
133
|
+
*
|
|
134
|
+
* The error channel is `unknown` because an abort reason is whatever the
|
|
135
|
+
* caller chose.
|
|
136
|
+
*/
|
|
137
|
+
export function withDeadlineEffect(operation, options) {
|
|
138
|
+
return Effect.suspend(() => {
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const forwardAbort = () => controller.abort(options.signal?.reason);
|
|
141
|
+
options.signal?.addEventListener("abort", forwardAbort, { once: true });
|
|
142
|
+
if (options.signal?.aborted)
|
|
143
|
+
forwardAbort();
|
|
144
|
+
// One race, not a race nested in a race: every contender is a forked
|
|
145
|
+
// fiber, and forks are most of what a deadline costs (P1-S02 measured
|
|
146
|
+
// about 2µs per raceFirst). Fork order is unchanged — operation, abort,
|
|
147
|
+
// timer — and the timer still aborts the signal before anything is
|
|
148
|
+
// interrupted, so the abort contender wins with the labelled reason.
|
|
149
|
+
const contenders = [
|
|
150
|
+
Effect.suspend(() => operation(controller.signal)),
|
|
151
|
+
fromSignal(controller.signal),
|
|
152
|
+
];
|
|
153
|
+
if (options.timeoutMs !== undefined) {
|
|
154
|
+
contenders.push(Effect.sleep(Duration.millis(options.timeoutMs)).pipe(Effect.andThen(Effect.sync(() => controller.abort(options.timeoutError))), Effect.andThen(Effect.never)));
|
|
155
|
+
}
|
|
156
|
+
return Effect.raceAllFirst(contenders).pipe(Effect.ensuring(Effect.sync(() => {
|
|
157
|
+
controller.abort();
|
|
158
|
+
options.signal?.removeEventListener("abort", forwardAbort);
|
|
159
|
+
})));
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The only sanctioned fire-and-forget.
|
|
164
|
+
*
|
|
165
|
+
* On Workers the work is handed to `ctx.waitUntil`, so the platform keeps the
|
|
166
|
+
* invocation alive until it settles rather than freezing it mid-flight. The
|
|
167
|
+
* returned promise never rejects: the outcome is the effect's own business,
|
|
168
|
+
* and an unhandled rejection from background work helps nobody.
|
|
169
|
+
*/
|
|
170
|
+
export function detach(effect, ctx) {
|
|
171
|
+
const settled = runExit(effect).then(() => { }, () => { });
|
|
172
|
+
ctx?.waitUntil?.(settled);
|
|
173
|
+
return settled;
|
|
174
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The core's services, as Effect code sees them.
|
|
2
|
+
//
|
|
3
|
+
// createConnecta resolves storage, the vault, activity history, logging, and
|
|
4
|
+
// the rest of its configuration once, at construction. This module gives each
|
|
5
|
+
// of those a service key and assembles them into the layer behind that
|
|
6
|
+
// Connecta's runtime (an EdgeRuntime, src/runtime/run.ts), so an Effect
|
|
7
|
+
// program reads them from its context rather than from arguments threaded
|
|
8
|
+
// through every call. The shells — Registry, CatalogService, and the rest —
|
|
9
|
+
// stay constructible from plain arguments, as the tests construct them. The
|
|
10
|
+
// registry's storage programs read Storage and Logger too, but from their own
|
|
11
|
+
// partition rather than this runtime (see src/runtime/storage.ts for why).
|
|
12
|
+
//
|
|
13
|
+
// Every import from an optional module is type-only. An omitted activity
|
|
14
|
+
// module is a no-op recorder that builds no event and calls nothing, and the
|
|
15
|
+
// UI, vault, and activity implementations stay out of the root import graph
|
|
16
|
+
// (test/purity.test.ts).
|
|
17
|
+
import { Context, Effect, Layer } from "effect";
|
|
18
|
+
import { makeEdgeRuntime } from "./run.js";
|
|
19
|
+
/**
|
|
20
|
+
* The deployment's KVStorage: `config.storage`, or the memoryStorage() that
|
|
21
|
+
* stands in for it. `compareAndSet` is present exactly when the adapter
|
|
22
|
+
* implements it, so a caller that needs atomicity checks for it here.
|
|
23
|
+
*/
|
|
24
|
+
export class Storage extends Context.Service()("connecta/Storage") {
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The credential vault, or undefined when the deployment configures none —
|
|
28
|
+
* a required service whose value may be absent, not a default, so a program
|
|
29
|
+
* run without it provided fails to typecheck instead of finding no vault.
|
|
30
|
+
*/
|
|
31
|
+
export class Vault extends Context.Service()("connecta/Vault") {
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Connecta's diagnostic logger, already resolved: the configured Logger, a
|
|
35
|
+
* no-op for `"silent"`, or console output prefixed `[connecta]`. Effect's
|
|
36
|
+
* own logger is never used (test/purity.test.ts), because it honors neither
|
|
37
|
+
* `"silent"` nor the line format a deployment greps for.
|
|
38
|
+
*/
|
|
39
|
+
export class Logger extends Context.Service()("connecta/Logger") {
|
|
40
|
+
}
|
|
41
|
+
/** Payload-free activity history, or a recorder that records nothing. */
|
|
42
|
+
export class ActivityRecorder extends Context.Service()("connecta/ActivityRecorder") {
|
|
43
|
+
}
|
|
44
|
+
/** What createConnecta resolved at construction. */
|
|
45
|
+
export class ResolvedConfig extends Context.Service()("connecta/ResolvedConfig") {
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The request's hook for work that may outlive its response (a Worker's
|
|
49
|
+
* `ctx.waitUntil`). Per request, not per Connecta, so it is never part of the
|
|
50
|
+
* runtime: a request provides it with `Effect.provideService`, and a program
|
|
51
|
+
* outside any request sees undefined and keeps its work in the foreground.
|
|
52
|
+
*/
|
|
53
|
+
export const DeferredWork = Context.Reference("connecta/DeferredWork", { defaultValue: () => undefined });
|
|
54
|
+
/**
|
|
55
|
+
* Resolve `ConnectaConfig.logger`. `"silent"` discards every line; an omitted
|
|
56
|
+
* logger writes to the console with the `[connecta]` prefix.
|
|
57
|
+
*/
|
|
58
|
+
export function resolveLogger(logger) {
|
|
59
|
+
if (logger === "silent") {
|
|
60
|
+
return { debug() { }, info() { }, warn() { }, error() { } };
|
|
61
|
+
}
|
|
62
|
+
return logger ?? {
|
|
63
|
+
debug: (...a) => console.debug("[connecta]", ...a),
|
|
64
|
+
info: (...a) => console.info("[connecta]", ...a),
|
|
65
|
+
warn: (...a) => console.warn("[connecta]", ...a),
|
|
66
|
+
error: (...a) => console.error("[connecta]", ...a),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const NO_ACTIVITY = {
|
|
70
|
+
enabled: false,
|
|
71
|
+
recordTool: () => Effect.void,
|
|
72
|
+
recordDrift: () => Effect.void,
|
|
73
|
+
};
|
|
74
|
+
function activityRecorder(module, serverInfo, logger) {
|
|
75
|
+
const deployment = module.deploymentId !== undefined
|
|
76
|
+
? { deploymentId: module.deploymentId }
|
|
77
|
+
: {};
|
|
78
|
+
// A custom module's recorder that throws is logged like a store that
|
|
79
|
+
// throws: activity never changes the outcome of the work it describes.
|
|
80
|
+
const bestEffort = (record) => Effect.sync(() => {
|
|
81
|
+
try {
|
|
82
|
+
record();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
logger.warn("[connecta] activity record failed", error);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
return {
|
|
89
|
+
enabled: true,
|
|
90
|
+
recordTool: (request, input) => DeferredWork.use((defer) => bestEffort(() => module.recordTool({
|
|
91
|
+
sink: module.store,
|
|
92
|
+
recordTool: module.recordTool,
|
|
93
|
+
actor: request.actor,
|
|
94
|
+
requestId: request.requestId,
|
|
95
|
+
serverInfo,
|
|
96
|
+
...deployment,
|
|
97
|
+
...(defer ? { defer } : {}),
|
|
98
|
+
logger,
|
|
99
|
+
}, input))),
|
|
100
|
+
recordDrift: (input) => bestEffort(() => module.recordDrift({ sink: module.store, serverInfo, ...deployment, logger }, input)),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The layer behind a Connecta's runtime. Every service is a value resolved
|
|
105
|
+
* before this is called, so building the layer only assembles a context: it
|
|
106
|
+
* starts no fiber and touches no storage.
|
|
107
|
+
*/
|
|
108
|
+
function coreServices(options) {
|
|
109
|
+
const activity = options.activity
|
|
110
|
+
? activityRecorder(options.activity, options.config.serverInfo, options.logger)
|
|
111
|
+
: NO_ACTIVITY;
|
|
112
|
+
return Layer.succeedContext(Context.make(Storage, options.storage).pipe(Context.add(Vault, options.vault), Context.add(Logger, options.logger), Context.add(ActivityRecorder, activity), Context.add(ResolvedConfig, options.config)));
|
|
113
|
+
}
|
|
114
|
+
// Keyed by the root Registry: it is the per-Connecta object every internal
|
|
115
|
+
// caller already holds, and a Registry built directly (as the tests build
|
|
116
|
+
// them) simply has no runtime.
|
|
117
|
+
const runtimes = new WeakMap();
|
|
118
|
+
/**
|
|
119
|
+
* Create the runtime for the Connecta that owns `registry`. Runs nothing:
|
|
120
|
+
* the layer is built by the first run that needs it (see EdgeRuntime).
|
|
121
|
+
*/
|
|
122
|
+
export function createCoreRuntime(registry, options) {
|
|
123
|
+
const runtime = makeEdgeRuntime(coreServices(options));
|
|
124
|
+
runtimes.set(registry, runtime);
|
|
125
|
+
return runtime;
|
|
126
|
+
}
|
|
127
|
+
/** The runtime createConnecta made for this root registry, if it made one. */
|
|
128
|
+
export function coreRuntime(registry) {
|
|
129
|
+
return runtimes.get(registry);
|
|
130
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The Storage service, as the registry's programs use it.
|
|
2
|
+
//
|
|
3
|
+
// KVStorage stays Promise-shaped: it is what a deployment author implements,
|
|
4
|
+
// and nobody should need Effect to write a storage adapter. The operations
|
|
5
|
+
// below lift one call each into an effect that reads the adapter from the
|
|
6
|
+
// Storage service and fails with whatever the adapter threw, unwrapped, so a
|
|
7
|
+
// warning built from it reads exactly as it did.
|
|
8
|
+
//
|
|
9
|
+
// Which Storage a program sees is the registry's decision, not the runtime's.
|
|
10
|
+
// The Connecta runtime (src/runtime/services.ts) is keyed by the root
|
|
11
|
+
// Registry, and that is the wrong key here: a personal registry's storage is
|
|
12
|
+
// the root's namespaced to `principal:<key>:`, so handing its programs the
|
|
13
|
+
// runtime's Storage would read and write another partition. Each Registry —
|
|
14
|
+
// root, personal, or one a test built from plain arguments — therefore
|
|
15
|
+
// provides its own storage and logger to the programs it runs
|
|
16
|
+
// (`runOnPartition`). For the root registry those are the very values the
|
|
17
|
+
// runtime holds, because createConnecta passes the same two objects to both.
|
|
18
|
+
// A scoped view holds no storage of its own: it reaches Storage through the
|
|
19
|
+
// registry it delegates to, and the result stash is always the root's,
|
|
20
|
+
// because its capacity is runtime-wide.
|
|
21
|
+
import { Context, Effect } from "effect";
|
|
22
|
+
import { runEdge } from "./run.js";
|
|
23
|
+
import { Logger, Storage } from "./services.js";
|
|
24
|
+
/** Read one key; null when absent. */
|
|
25
|
+
export function storageGet(key) {
|
|
26
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
27
|
+
// Promise.resolve keeps an adapter that answers synchronously working,
|
|
28
|
+
// as `await` did; a synchronous throw is caught either way.
|
|
29
|
+
try: () => Promise.resolve(storage.get(key)),
|
|
30
|
+
catch: (error) => error,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
/** Write one key, with the adapter's optional TTL. */
|
|
34
|
+
export function storageSet(key, value, options) {
|
|
35
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
36
|
+
try: () => Promise.resolve(storage.set(key, value, options)),
|
|
37
|
+
catch: (error) => error,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Delete one key. */
|
|
41
|
+
export function storageDelete(key) {
|
|
42
|
+
return Storage.use((storage) => Effect.tryPromise({
|
|
43
|
+
try: () => Promise.resolve(storage.delete(key)),
|
|
44
|
+
catch: (error) => error,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
// One context per partition object, built on first use. A registry's options
|
|
48
|
+
// object lives exactly as long as the registry, so the WeakMap never holds a
|
|
49
|
+
// partition past its owner.
|
|
50
|
+
const partitions = new WeakMap();
|
|
51
|
+
function partitionContext(partition) {
|
|
52
|
+
let context = partitions.get(partition);
|
|
53
|
+
if (!context) {
|
|
54
|
+
context = Context.make(Storage, partition.storage).pipe(Context.add(Logger, partition.logger));
|
|
55
|
+
partitions.set(partition, context);
|
|
56
|
+
}
|
|
57
|
+
return context;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run a registry program against the registry's own storage and logger, at a
|
|
61
|
+
* Promise boundary. Resolves and rejects as runEdge does: the original error,
|
|
62
|
+
* never a wrapper.
|
|
63
|
+
*/
|
|
64
|
+
export function runOnPartition(effect, partition) {
|
|
65
|
+
return runEdge(Effect.provideContext(effect, partitionContext(partition)));
|
|
66
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,18 +1,119 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { aggregateCallAdmissionSnapshots } from "./call-admission.js";
|
|
2
3
|
import { isAdmittingExecutor } from "./executor-admission.js";
|
|
3
4
|
import { createMcpRoute, MCP_CORS_HEADERS } from "./routes/mcp.js";
|
|
4
5
|
import { routeOAuthCallback, } from "./routes/oauth.js";
|
|
6
|
+
import { runEdge } from "./runtime/run.js";
|
|
5
7
|
import { withSecurityHeaders, } from "./routes/shared.js";
|
|
6
8
|
/**
|
|
7
9
|
* Build the Web-standard fetch handler.
|
|
8
10
|
*
|
|
9
11
|
* Route ordering is the contract: private mutation routes precede wildcard
|
|
10
12
|
* OPTIONS, and the security wrapper is applied to every response, including 404s.
|
|
13
|
+
*
|
|
14
|
+
* Each request is one fiber tied to `request.signal`: when its caller leaves,
|
|
15
|
+
* the request stops waiting on whatever it was waiting on, and what it holds
|
|
16
|
+
* is released (routes/mcp.ts). It runs on no Connecta runtime, because
|
|
17
|
+
* `/health` and a closed deployment's 503 must keep answering after
|
|
18
|
+
* `close()` has disposed that runtime.
|
|
11
19
|
*/
|
|
12
20
|
export function createFetchHandler(opts) {
|
|
13
21
|
const { auth, publicUrl, registry } = opts;
|
|
14
22
|
const routeMcp = createMcpRoute(opts);
|
|
15
|
-
|
|
23
|
+
// The first provider's metadata answer for this request, if any has one.
|
|
24
|
+
const metadata = (request, baseUrl) => Effect.gen(function* () {
|
|
25
|
+
for (const provider of auth) {
|
|
26
|
+
const handleMetadata = provider.handleMetadata?.bind(provider);
|
|
27
|
+
if (handleMetadata) {
|
|
28
|
+
const response = yield* Effect.promise(async () => handleMetadata(request, baseUrl));
|
|
29
|
+
if (response)
|
|
30
|
+
return response;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
});
|
|
35
|
+
const health = async () => {
|
|
36
|
+
// The executor is required, so code admission always has a shape to
|
|
37
|
+
// report: either the executor's own pool or the fallback controller
|
|
38
|
+
// wrapped around it at construction.
|
|
39
|
+
const codeAdmission = isAdmittingExecutor(opts.executor)
|
|
40
|
+
? opts.executor.admissionSnapshot?.()
|
|
41
|
+
: undefined;
|
|
42
|
+
return Response.json({
|
|
43
|
+
status: "ok",
|
|
44
|
+
connectors: registry.listConnectors().length,
|
|
45
|
+
server: opts.serverInfo,
|
|
46
|
+
// Which sandbox, not how it is tuned: `connecta doctor` reports the
|
|
47
|
+
// executor it just exercised rather than assuming one, and a
|
|
48
|
+
// deployment whose executor identifies as nothing omits the key
|
|
49
|
+
// instead of inviting a guess (#368).
|
|
50
|
+
...(opts.executorName !== undefined
|
|
51
|
+
? { executor: { name: opts.executorName } }
|
|
52
|
+
: {}),
|
|
53
|
+
// Counts only, from refreshes that already happened — the endpoint
|
|
54
|
+
// asks no downstream anything, and `connecta doctor` reads it to
|
|
55
|
+
// report a stale allowlist without a probe of its own (#343).
|
|
56
|
+
// Stable 64-bit hashes preserve that shape without publishing ids.
|
|
57
|
+
catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
|
|
58
|
+
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
|
|
59
|
+
const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
60
|
+
return [key, report];
|
|
61
|
+
}))),
|
|
62
|
+
admission: {
|
|
63
|
+
policy: "global-fifo",
|
|
64
|
+
requests: opts.requestAdmission.snapshot(),
|
|
65
|
+
code: codeAdmission ?? { managedByExecutor: true },
|
|
66
|
+
downstreamCalls: {
|
|
67
|
+
policy: "connector-partitioned-per-runtime",
|
|
68
|
+
aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
|
|
69
|
+
},
|
|
70
|
+
reservedRoutes: [
|
|
71
|
+
"/health",
|
|
72
|
+
...(opts.ui?.reservedPaths ?? []),
|
|
73
|
+
...(opts.ui && opts.activity?.list ? ["/activity"] : []),
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
const route = (context) => Effect.gen(function* () {
|
|
80
|
+
const { request, path, baseUrl } = context;
|
|
81
|
+
// Private mutations own OPTIONS so they never inherit wildcard CORS.
|
|
82
|
+
const ui = opts.ui;
|
|
83
|
+
if (ui) {
|
|
84
|
+
const uiResponse = yield* Effect.promise(() => ui.handle(context));
|
|
85
|
+
if (uiResponse)
|
|
86
|
+
return uiResponse;
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "OPTIONS") {
|
|
89
|
+
const preflight = yield* routeMcp.handle(context);
|
|
90
|
+
if (preflight)
|
|
91
|
+
return preflight;
|
|
92
|
+
const response = yield* metadata(request, baseUrl);
|
|
93
|
+
if (response)
|
|
94
|
+
return response;
|
|
95
|
+
return new Response(null, {
|
|
96
|
+
status: 204,
|
|
97
|
+
headers: MCP_CORS_HEADERS,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (path.startsWith("/.well-known/")) {
|
|
101
|
+
const response = yield* metadata(request, baseUrl);
|
|
102
|
+
if (response)
|
|
103
|
+
return response;
|
|
104
|
+
return new Response("Not Found", { status: 404 });
|
|
105
|
+
}
|
|
106
|
+
if (path === "/health")
|
|
107
|
+
return yield* Effect.promise(health);
|
|
108
|
+
const oauthCallback = yield* routeOAuthCallback(context);
|
|
109
|
+
if (oauthCallback)
|
|
110
|
+
return oauthCallback;
|
|
111
|
+
const mcp = yield* routeMcp.handle(context);
|
|
112
|
+
if (mcp)
|
|
113
|
+
return mcp;
|
|
114
|
+
return new Response("Not Found", { status: 404 });
|
|
115
|
+
});
|
|
116
|
+
const serve = (request, runtimeContext) => Effect.suspend(() => {
|
|
16
117
|
const url = new URL(request.url);
|
|
17
118
|
const baseUrl = publicUrl ?? url.origin;
|
|
18
119
|
const path = url.pathname;
|
|
@@ -20,8 +121,9 @@ export function createFetchHandler(opts) {
|
|
|
20
121
|
? runtimeContext.waitUntil.bind(runtimeContext)
|
|
21
122
|
: undefined;
|
|
22
123
|
const originRefusal = routeMcp.rejectOrigin(request);
|
|
23
|
-
if (originRefusal)
|
|
24
|
-
return withSecurityHeaders(originRefusal, url, path);
|
|
124
|
+
if (originRefusal) {
|
|
125
|
+
return Effect.succeed(withSecurityHeaders(originRefusal, url, path));
|
|
126
|
+
}
|
|
25
127
|
// Container and orchestrator probes reach /health over plain HTTP on
|
|
26
128
|
// loopback, where no proxy has set X-Forwarded-Proto. Redirecting them to
|
|
27
129
|
// the public origin would make an internal liveness check depend on
|
|
@@ -43,10 +145,10 @@ export function createFetchHandler(opts) {
|
|
|
43
145
|
target.pathname = path === "/ui" ? "/" : url.pathname;
|
|
44
146
|
target.search = url.search;
|
|
45
147
|
target.hash = "";
|
|
46
|
-
return withSecurityHeaders(new Response(null, {
|
|
148
|
+
return Effect.succeed(withSecurityHeaders(new Response(null, {
|
|
47
149
|
status: 308,
|
|
48
150
|
headers: { Location: target.toString() },
|
|
49
|
-
}), url, path);
|
|
151
|
+
}), url, path));
|
|
50
152
|
}
|
|
51
153
|
const context = {
|
|
52
154
|
request,
|
|
@@ -57,89 +159,9 @@ export function createFetchHandler(opts) {
|
|
|
57
159
|
defer,
|
|
58
160
|
runtimeContext,
|
|
59
161
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return uiResponse;
|
|
65
|
-
if (request.method === "OPTIONS") {
|
|
66
|
-
const preflight = await routeMcp.handle(context);
|
|
67
|
-
if (preflight)
|
|
68
|
-
return preflight;
|
|
69
|
-
for (const provider of auth) {
|
|
70
|
-
if (provider.handleMetadata) {
|
|
71
|
-
const response = await provider.handleMetadata(request, baseUrl);
|
|
72
|
-
if (response)
|
|
73
|
-
return response;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return new Response(null, {
|
|
77
|
-
status: 204,
|
|
78
|
-
headers: MCP_CORS_HEADERS,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
if (path.startsWith("/.well-known/")) {
|
|
82
|
-
for (const provider of auth) {
|
|
83
|
-
if (provider.handleMetadata) {
|
|
84
|
-
const response = await provider.handleMetadata(request, baseUrl);
|
|
85
|
-
if (response)
|
|
86
|
-
return response;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return new Response("Not Found", { status: 404 });
|
|
90
|
-
}
|
|
91
|
-
if (path === "/health") {
|
|
92
|
-
// The executor is required, so code admission always has a shape to
|
|
93
|
-
// report: either the executor's own pool or the fallback controller
|
|
94
|
-
// wrapped around it at construction.
|
|
95
|
-
const codeAdmission = isAdmittingExecutor(opts.executor)
|
|
96
|
-
? opts.executor.admissionSnapshot?.()
|
|
97
|
-
: undefined;
|
|
98
|
-
return Response.json({
|
|
99
|
-
status: "ok",
|
|
100
|
-
connectors: registry.listConnectors().length,
|
|
101
|
-
server: opts.serverInfo,
|
|
102
|
-
// Which sandbox, not how it is tuned: `connecta doctor` reports the
|
|
103
|
-
// executor it just exercised rather than assuming one, and a
|
|
104
|
-
// deployment whose executor identifies as nothing omits the key
|
|
105
|
-
// instead of inviting a guess (#368).
|
|
106
|
-
...(opts.executorName !== undefined
|
|
107
|
-
? { executor: { name: opts.executorName } }
|
|
108
|
-
: {}),
|
|
109
|
-
// Counts only, from refreshes that already happened — the endpoint
|
|
110
|
-
// asks no downstream anything, and `connecta doctor` reads it to
|
|
111
|
-
// report a stale allowlist without a probe of its own (#343).
|
|
112
|
-
// Stable 64-bit hashes preserve that shape without publishing ids.
|
|
113
|
-
catalogDrift: Object.fromEntries(await Promise.all(Object.entries(registry.catalogDriftSnapshot()).map(async ([id, report]) => {
|
|
114
|
-
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id)));
|
|
115
|
-
const key = Array.from(hash.subarray(0, 8), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
116
|
-
return [key, report];
|
|
117
|
-
}))),
|
|
118
|
-
admission: {
|
|
119
|
-
policy: "global-fifo",
|
|
120
|
-
requests: opts.requestAdmission.snapshot(),
|
|
121
|
-
code: codeAdmission ?? { managedByExecutor: true },
|
|
122
|
-
downstreamCalls: {
|
|
123
|
-
policy: "connector-partitioned-per-runtime",
|
|
124
|
-
aggregate: aggregateCallAdmissionSnapshots(Object.values(registry.callAdmissionSnapshot())),
|
|
125
|
-
},
|
|
126
|
-
reservedRoutes: [
|
|
127
|
-
"/health",
|
|
128
|
-
...(opts.ui?.reservedPaths ?? []),
|
|
129
|
-
...(opts.ui && opts.activity?.list ? ["/activity"] : []),
|
|
130
|
-
],
|
|
131
|
-
},
|
|
132
|
-
...(opts.deploymentInfo ? { deployment: opts.deploymentInfo } : {}),
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
const oauthCallback = await routeOAuthCallback(context);
|
|
136
|
-
if (oauthCallback)
|
|
137
|
-
return oauthCallback;
|
|
138
|
-
const mcp = await routeMcp.handle(context);
|
|
139
|
-
if (mcp)
|
|
140
|
-
return mcp;
|
|
141
|
-
return new Response("Not Found", { status: 404 });
|
|
142
|
-
};
|
|
143
|
-
return withSecurityHeaders(await route(), url, path);
|
|
162
|
+
return Effect.map(route(context), (response) => withSecurityHeaders(response, url, path));
|
|
163
|
+
});
|
|
164
|
+
return function fetch(request, runtimeContext) {
|
|
165
|
+
return runEdge(serve(request, runtimeContext), { signal: request.signal });
|
|
144
166
|
};
|
|
145
167
|
}
|
package/dist/skills.js
CHANGED
|
@@ -47,7 +47,7 @@ Caught Connecta errors expose \`message\`, \`code\`, \`retryable\`, and \`detail
|
|
|
47
47
|
|
|
48
48
|
For a direct call, \`resultMode: "value"\` unwraps the result. \`timeoutMs\` sets its deadline. Every call makes one attempt; use the returned error classification and retry hint to decide whether to reissue. \`diagnostics: true\` adds timing.
|
|
49
49
|
|
|
50
|
-
\`get_result({ id, offset?, maxBytes? })\` returns \`{
|
|
50
|
+
\`get_result({ id, offset?, maxBytes? })\` returns a one-line JSON header \`{ resultId, offset, bytes, totalBytes, hasMore, nextAction? }\`, a newline, and then the page as raw text, for a direct-call result. Both sizes are byte counts: \`maxBytes\` must be a whole number at least 1, defaults to the result's cap, and is clamped to it; \`offset\` must be a whole number at least 0 and defaults to 0. An offset inside a multi-byte character moves back to its first byte, and the header reports the served offset. Follow \`nextAction\` until \`hasMore\` is false to reassemble pages. An unknown or expired id is an error.
|
|
51
51
|
|
|
52
52
|
Limits: 20 host calls per run and a 15-second deadline per host call.
|
|
53
53
|
|
package/dist/storage/file.js
CHANGED
|
@@ -259,8 +259,9 @@ export function fileStorage(path, opts = {}) {
|
|
|
259
259
|
throw new Error(`[connecta] state file ${path} is closed.`);
|
|
260
260
|
};
|
|
261
261
|
const logger = opts.logger ?? console;
|
|
262
|
-
//
|
|
263
|
-
//
|
|
262
|
+
// With a sealing vault, downstream OAuth tokens here are ciphertext; without
|
|
263
|
+
// one they are cleartext, and OAuth flow state is plaintext either way. Keep
|
|
264
|
+
// the file owner-only regardless. Repair is best-effort: chmod is a no-op or throws on
|
|
264
265
|
// non-POSIX filesystems, and a loose mode must never keep the store from
|
|
265
266
|
// starting.
|
|
266
267
|
const tighten = () => {
|
|
@@ -377,5 +378,41 @@ export function fileStorage(path, opts = {}) {
|
|
|
377
378
|
.filter((key) => Boolean(fresh(key)) && key.startsWith(prefix))
|
|
378
379
|
.sort();
|
|
379
380
|
},
|
|
381
|
+
// Atomic twice over: the lock makes this process the only writer of the
|
|
382
|
+
// file, and nothing between the comparison and the persisted rename
|
|
383
|
+
// yields, so no other call on this store can interleave. A write that
|
|
384
|
+
// cannot be persisted is rolled back so memory never claims a value the
|
|
385
|
+
// file does not hold.
|
|
386
|
+
async compareAndSet(key, expected, next, opts) {
|
|
387
|
+
assertOpen();
|
|
388
|
+
lock.assertHeld();
|
|
389
|
+
const previous = fresh(key);
|
|
390
|
+
if ((previous?.value ?? null) !== expected)
|
|
391
|
+
return false;
|
|
392
|
+
if (next === null) {
|
|
393
|
+
if (!previous)
|
|
394
|
+
return true;
|
|
395
|
+
delete data[key];
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
data[key] = {
|
|
399
|
+
value: next,
|
|
400
|
+
...(opts?.ttlSeconds
|
|
401
|
+
? { exp: Date.now() + opts.ttlSeconds * 1000 }
|
|
402
|
+
: {}),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
persist();
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (previous)
|
|
410
|
+
data[key] = previous;
|
|
411
|
+
else
|
|
412
|
+
delete data[key];
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
return true;
|
|
416
|
+
},
|
|
380
417
|
};
|
|
381
418
|
}
|