@granular-software/sdk 0.3.4 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3917,6 +3917,9 @@ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
3917
3917
  GlobalWebSocket = globalThis.WebSocket;
3918
3918
  }
3919
3919
  var READY_STATE_OPEN = 1;
3920
+ var TOKEN_REFRESH_LEEWAY_MS = 2 * 60 * 1e3;
3921
+ var TOKEN_REFRESH_RETRY_MS = 30 * 1e3;
3922
+ var MAX_TIMER_DELAY_MS = 2147483647;
3920
3923
  var WSClient = class {
3921
3924
  ws = null;
3922
3925
  url;
@@ -3930,6 +3933,7 @@ var WSClient = class {
3930
3933
  doc = Automerge.init();
3931
3934
  syncState = Automerge.initSyncState();
3932
3935
  reconnectTimer = null;
3936
+ tokenRefreshTimer = null;
3933
3937
  isExplicitlyDisconnected = false;
3934
3938
  options;
3935
3939
  constructor(options) {
@@ -3938,12 +3942,109 @@ var WSClient = class {
3938
3942
  this.sessionId = options.sessionId;
3939
3943
  this.token = options.token;
3940
3944
  }
3945
+ clearTokenRefreshTimer() {
3946
+ if (this.tokenRefreshTimer) {
3947
+ clearTimeout(this.tokenRefreshTimer);
3948
+ this.tokenRefreshTimer = null;
3949
+ }
3950
+ }
3951
+ decodeBase64Url(payload) {
3952
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3953
+ const padded = base64 + "=".repeat((4 - (base64.length % 4 || 4)) % 4);
3954
+ if (typeof atob === "function") {
3955
+ return atob(padded);
3956
+ }
3957
+ const maybeBuffer = globalThis.Buffer;
3958
+ if (maybeBuffer) {
3959
+ return maybeBuffer.from(padded, "base64").toString("utf8");
3960
+ }
3961
+ throw new Error("No base64 decoder available");
3962
+ }
3963
+ getTokenExpiryMs(token) {
3964
+ const parts = token.split(".");
3965
+ if (parts.length < 2) {
3966
+ return null;
3967
+ }
3968
+ try {
3969
+ const payloadRaw = this.decodeBase64Url(parts[1]);
3970
+ const payload = JSON.parse(payloadRaw);
3971
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
3972
+ return null;
3973
+ }
3974
+ return payload.exp * 1e3;
3975
+ } catch {
3976
+ return null;
3977
+ }
3978
+ }
3979
+ scheduleTokenRefresh() {
3980
+ this.clearTokenRefreshTimer();
3981
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
3982
+ return;
3983
+ }
3984
+ const expiresAt = this.getTokenExpiryMs(this.token);
3985
+ if (!expiresAt) {
3986
+ return;
3987
+ }
3988
+ const refreshInMs = Math.max(1e3, expiresAt - Date.now() - TOKEN_REFRESH_LEEWAY_MS);
3989
+ const delay = Math.min(refreshInMs, MAX_TIMER_DELAY_MS);
3990
+ this.tokenRefreshTimer = setTimeout(() => {
3991
+ void this.refreshTokenInBackground();
3992
+ }, delay);
3993
+ }
3994
+ async refreshTokenInBackground() {
3995
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
3996
+ return;
3997
+ }
3998
+ try {
3999
+ const refreshedToken = await this.options.tokenProvider();
4000
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4001
+ throw new Error("Token provider returned no token");
4002
+ }
4003
+ this.token = refreshedToken;
4004
+ this.scheduleTokenRefresh();
4005
+ } catch (error) {
4006
+ if (this.isExplicitlyDisconnected) {
4007
+ return;
4008
+ }
4009
+ console.warn("[Granular] Token refresh failed, retrying soon:", error);
4010
+ this.clearTokenRefreshTimer();
4011
+ this.tokenRefreshTimer = setTimeout(() => {
4012
+ void this.refreshTokenInBackground();
4013
+ }, TOKEN_REFRESH_RETRY_MS);
4014
+ }
4015
+ }
4016
+ async resolveTokenForConnect() {
4017
+ if (!this.options.tokenProvider) {
4018
+ return this.token;
4019
+ }
4020
+ const expiresAt = this.getTokenExpiryMs(this.token);
4021
+ const shouldRefresh = expiresAt !== null && expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;
4022
+ if (!shouldRefresh) {
4023
+ return this.token;
4024
+ }
4025
+ try {
4026
+ const refreshedToken = await this.options.tokenProvider();
4027
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4028
+ throw new Error("Token provider returned no token");
4029
+ }
4030
+ this.token = refreshedToken;
4031
+ return refreshedToken;
4032
+ } catch (error) {
4033
+ if (expiresAt > Date.now()) {
4034
+ console.warn("[Granular] Token refresh failed, using current token:", error);
4035
+ return this.token;
4036
+ }
4037
+ throw error;
4038
+ }
4039
+ }
3941
4040
  /**
3942
4041
  * Connect to the WebSocket server
3943
4042
  * @returns {Promise<void>} Resolves when connection is open
3944
4043
  */
3945
4044
  async connect() {
4045
+ const token = await this.resolveTokenForConnect();
3946
4046
  this.isExplicitlyDisconnected = false;
4047
+ this.scheduleTokenRefresh();
3947
4048
  if (this.reconnectTimer) {
3948
4049
  clearTimeout(this.reconnectTimer);
3949
4050
  this.reconnectTimer = null;
@@ -3963,7 +4064,7 @@ var WSClient = class {
3963
4064
  try {
3964
4065
  const wsUrl = new URL(this.url);
3965
4066
  wsUrl.searchParams.set("sessionId", this.sessionId);
3966
- wsUrl.searchParams.set("token", this.token);
4067
+ wsUrl.searchParams.set("token", token);
3967
4068
  this.ws = new WebSocketClass(wsUrl.toString());
3968
4069
  if (!this.ws) throw new Error("Failed to create WebSocket");
3969
4070
  const socket = this.ws;
@@ -4168,10 +4269,10 @@ var WSClient = class {
4168
4269
  const snapshotMessage = message;
4169
4270
  try {
4170
4271
  const bytes = new Uint8Array(snapshotMessage.data);
4171
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4272
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4172
4273
  this.doc = Automerge.load(bytes);
4173
4274
  this.emit("sync", this.doc);
4174
- console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
4275
+ console.log("[Granular DEBUG] Automerge session snapshot loaded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
4175
4276
  } catch (e) {
4176
4277
  console.warn("[Granular] Failed to load snapshot message", e);
4177
4278
  }
@@ -4334,6 +4435,7 @@ var WSClient = class {
4334
4435
  clearTimeout(this.reconnectTimer);
4335
4436
  this.reconnectTimer = null;
4336
4437
  }
4438
+ this.clearTokenRefreshTimer();
4337
4439
  if (this.ws) {
4338
4440
  this.ws.close(1e3, "Client disconnect");
4339
4441
  this.ws = null;
@@ -4364,6 +4466,17 @@ var Session = class {
4364
4466
  this.setupEventHandlers();
4365
4467
  this.setupToolInvokeHandler();
4366
4468
  }
4469
+ buildLegacyEffectContext() {
4470
+ return {
4471
+ effectClientId: this.clientId,
4472
+ sandboxId: "",
4473
+ environmentId: "",
4474
+ sessionId: "",
4475
+ user: {
4476
+ subjectId: ""
4477
+ }
4478
+ };
4479
+ }
4367
4480
  // --- Public API ---
4368
4481
  get document() {
4369
4482
  return this.client.doc;
@@ -4402,155 +4515,30 @@ var Session = class {
4402
4515
  });
4403
4516
  return result;
4404
4517
  }
4405
- /**
4406
- * Publish tools to the sandbox and register handlers for reverse-RPC.
4407
- *
4408
- * Tools can be:
4409
- * - **Instance methods**: set `className` (handler receives `(objectId, params)`)
4410
- * - **Static methods**: set `className` + `static: true` (handler receives `(params)`)
4411
- * - **Global tools**: omit `className` (handler receives `(params)`)
4412
- *
4413
- * Both `inputSchema` and `outputSchema` accept JSON Schema objects. The
4414
- * `outputSchema` drives the return type in the auto-generated TypeScript
4415
- * class declarations that sandbox code imports from `./sandbox-tools`.
4416
- *
4417
- * This method:
4418
- * 1. Extracts tool schemas (including `outputSchema`) from the provided tools
4419
- * 2. Publishes them via `client.publishRawToolCatalog` RPC
4420
- * 3. Registers handlers locally for `tool.invoke` RPC calls
4421
- * 4. Returns the `domainRevision` needed for job submission
4422
- *
4423
- * @param tools - Array of tools with handlers
4424
- * @param revision - Optional revision string (default: "1.0.0")
4425
- * @returns PublishToolsResult with domainRevision
4426
- *
4427
- * @example
4428
- * ```typescript
4429
- * await env.publishTools([
4430
- * {
4431
- * name: 'get_bio',
4432
- * description: 'Get biography of an author',
4433
- * className: 'author',
4434
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4435
- * outputSchema: { type: 'object', properties: { bio: { type: 'string' } }, required: ['bio'] },
4436
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4437
- * },
4438
- * ]);
4439
- * ```
4440
- */
4441
4518
  async publishTools(tools, revision = "1.0.0") {
4442
- const schemas = tools.map((tool) => ({
4443
- name: tool.name,
4444
- description: tool.description,
4445
- inputSchema: tool.inputSchema,
4446
- outputSchema: tool.outputSchema,
4447
- stability: tool.stability || "stable",
4448
- provenance: tool.provenance || { source: "mcp" },
4449
- tags: tool.tags,
4450
- className: tool.className,
4451
- static: tool.static
4452
- }));
4453
- const result = await this.client.call("client.publishRawToolCatalog", {
4454
- clientId: this.clientId,
4455
- revision,
4456
- tools: schemas
4457
- });
4458
- if (!result.accepted || !result.domainRevision) {
4459
- throw new Error(`Failed to publish tools: ${JSON.stringify(result.rejected)}`);
4460
- }
4461
- for (const tool of tools) {
4462
- this.toolHandlers.set(tool.name, tool.handler);
4463
- if (tool.className && !tool.static) {
4464
- this.instanceTools.add(tool.name);
4465
- } else {
4466
- this.instanceTools.delete(tool.name);
4467
- }
4468
- }
4469
- this.currentDomainRevision = result.domainRevision;
4470
- return {
4471
- accepted: result.accepted,
4472
- domainRevision: result.domainRevision,
4473
- rejected: result.rejected
4474
- };
4519
+ throw new Error(
4520
+ "Environment-scoped effect publication was removed. Declare effects in the manifest and register live handlers with granular.registerEffects(environment.sandboxId, effects)."
4521
+ );
4475
4522
  }
4476
- /**
4477
- * Publish a single effect (tool) to the sandbox.
4478
- *
4479
- * Adds the effect to the local registry and re-publishes the
4480
- * full tool catalog to the server. If an effect with the same
4481
- * name already exists, it is replaced.
4482
- *
4483
- * @param effect - The effect (tool) to publish
4484
- * @returns PublishToolsResult with domainRevision
4485
- *
4486
- * @example
4487
- * ```typescript
4488
- * await env.publishEffect({
4489
- * name: 'get_bio',
4490
- * description: 'Get biography of an author',
4491
- * className: 'author',
4492
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4493
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4494
- * });
4495
- * ```
4496
- */
4497
4523
  async publishEffect(effect) {
4498
- this.effects.set(effect.name, effect);
4499
- return this._syncEffects();
4524
+ throw new Error(
4525
+ "Environment-scoped effect publication was removed. Use granular.registerEffect(environment.sandboxId, effect)."
4526
+ );
4500
4527
  }
4501
- /**
4502
- * Publish multiple effects (tools) at once.
4503
- *
4504
- * Adds all effects to the local registry and re-publishes the
4505
- * full tool catalog in a single RPC call.
4506
- *
4507
- * @param effects - Array of effects to publish
4508
- * @returns PublishToolsResult with domainRevision
4509
- *
4510
- * @example
4511
- * ```typescript
4512
- * await env.publishEffects([
4513
- * { name: 'get_bio', description: '...', inputSchema: {}, handler: async (id) => ({}) },
4514
- * { name: 'search', description: '...', inputSchema: {}, handler: async (params) => ({}) },
4515
- * ]);
4516
- * ```
4517
- */
4518
4528
  async publishEffects(effects) {
4519
- for (const effect of effects) {
4520
- this.effects.set(effect.name, effect);
4521
- }
4522
- return this._syncEffects();
4529
+ throw new Error(
4530
+ "Environment-scoped effect publication was removed. Use granular.registerEffects(environment.sandboxId, effects)."
4531
+ );
4523
4532
  }
4524
- /**
4525
- * Remove an effect by name and re-publish the remaining catalog.
4526
- *
4527
- * @param name - The name of the effect to remove
4528
- * @returns PublishToolsResult with domainRevision
4529
- */
4530
4533
  async unpublishEffect(name) {
4531
- this.effects.delete(name);
4532
- this.toolHandlers.delete(name);
4533
- this.instanceTools.delete(name);
4534
- return this._syncEffects();
4534
+ throw new Error(
4535
+ "Environment-scoped effect publication was removed. Use granular.unregisterEffect(environment.sandboxId, effectName)."
4536
+ );
4535
4537
  }
4536
- /**
4537
- * Remove all effects and publish an empty catalog.
4538
- *
4539
- * @returns PublishToolsResult with domainRevision
4540
- */
4541
4538
  async unpublishAllEffects() {
4542
- this.effects.clear();
4543
- this.toolHandlers.clear();
4544
- this.instanceTools.clear();
4545
- return this._syncEffects();
4546
- }
4547
- /**
4548
- * Internal: re-publish the full effect catalog to the server.
4549
- * Called after any mutation to the effects Map.
4550
- */
4551
- async _syncEffects() {
4552
- const allEffects = Array.from(this.effects.values());
4553
- return this.publishTools(allEffects);
4539
+ throw new Error(
4540
+ "Environment-scoped effect publication was removed. Use granular.unregisterAllEffects(environment.sandboxId)."
4541
+ );
4554
4542
  }
4555
4543
  /**
4556
4544
  * Submit a job to execute code in the sandbox.
@@ -4564,14 +4552,14 @@ var Session = class {
4564
4552
  * const books = await tolkien.get_books();
4565
4553
  * ```
4566
4554
  *
4567
- * Tool calls (instance methods, static methods, global functions) trigger
4568
- * `tool.invoke` RPC back to this client, where the registered handlers
4555
+ * Effect calls (instance methods, static methods, global functions) trigger
4556
+ * `effect.invoke` RPC back to the sandbox effect host, where the registered handlers
4569
4557
  * execute locally and return the result to the sandbox.
4570
4558
  */
4571
4559
  async submitJob(code, domainRevision) {
4572
4560
  const revision = domainRevision || this.currentDomainRevision || this.client.doc?.domain?.active || void 0;
4573
4561
  if (!revision) {
4574
- throw new Error("No domain revision available. Call publishTools() or ensure schema is activated.");
4562
+ throw new Error("No domain revision available. Register live effects or ensure the build schema is activated.");
4575
4563
  }
4576
4564
  const result = await this.client.call("job.submit", {
4577
4565
  domainRevision: revision,
@@ -4603,10 +4591,10 @@ var Session = class {
4603
4591
  await this.client.call("prompt.answer", { promptId, value: answer });
4604
4592
  }
4605
4593
  /**
4606
- * Get the current list of available tools.
4607
- * Consolidates tools from all connected clients.
4594
+ * Get the current list of available effects.
4595
+ * Consolidates effect declarations and live availability for the session.
4608
4596
  */
4609
- getTools() {
4597
+ getEffects() {
4610
4598
  const doc = this.client.doc;
4611
4599
  const toolMap = /* @__PURE__ */ new Map();
4612
4600
  const domainPkg = doc.domain?.packages?.domain;
@@ -4651,10 +4639,32 @@ var Session = class {
4651
4639
  return Array.from(toolMap.values());
4652
4640
  }
4653
4641
  /**
4654
- * Subscribe to tool changes (added, removed, updated).
4642
+ * Backwards-compatible alias for `getEffects()`.
4643
+ */
4644
+ getTools() {
4645
+ return this.getEffects();
4646
+ }
4647
+ /**
4648
+ * Subscribe to effect changes (added, removed, updated).
4655
4649
  * @param callback - Function called with change events
4656
4650
  * @returns Unsubscribe function
4657
4651
  */
4652
+ onEffectsChanged(callback) {
4653
+ const handler = (data) => callback(data);
4654
+ if (!this.eventListeners.has("effects:changed")) {
4655
+ this.eventListeners.set("effects:changed", []);
4656
+ }
4657
+ this.eventListeners.get("effects:changed").push(handler);
4658
+ return () => {
4659
+ const listeners = this.eventListeners.get("effects:changed");
4660
+ if (listeners) {
4661
+ this.eventListeners.set("effects:changed", listeners.filter((h) => h !== handler));
4662
+ }
4663
+ };
4664
+ }
4665
+ /**
4666
+ * Backwards-compatible alias for `onEffectsChanged()`.
4667
+ */
4658
4668
  onToolsChanged(callback) {
4659
4669
  const handler = (data) => callback(data);
4660
4670
  if (!this.eventListeners.has("tools:changed")) {
@@ -4767,7 +4777,7 @@ import { ${allImports} } from "./sandbox-tools";
4767
4777
  }
4768
4778
  }
4769
4779
  if (globalTools && globalTools.length > 0) {
4770
- docs2 += "## Global Tools\n\n";
4780
+ docs2 += "## Global Effects\n\n";
4771
4781
  for (const tool of globalTools) {
4772
4782
  docs2 += `### ${tool.name}
4773
4783
 
@@ -4785,10 +4795,10 @@ import { ${allImports} } from "./sandbox-tools";
4785
4795
  return docs2;
4786
4796
  }
4787
4797
  if (!tools || tools.length === 0) {
4788
- return "No tools available in this domain.";
4798
+ return "No effects available in this domain.";
4789
4799
  }
4790
- let docs = "# Available Tools\n\n";
4791
- docs += "Import tools from `./sandbox-tools` and call them with await:\n\n";
4800
+ let docs = "# Available Effects\n\n";
4801
+ docs += "Import effects from `./sandbox-tools` and call them with await:\n\n";
4792
4802
  docs += '```typescript\nimport { tools } from "./sandbox-tools";\n\n';
4793
4803
  docs += "// Example:\n";
4794
4804
  docs += `const result = await tools.${tools[0]?.name || "example"}(input);
@@ -4820,6 +4830,13 @@ import { ${allImports} } from "./sandbox-tools";
4820
4830
  * Close the session and disconnect from the sandbox
4821
4831
  */
4822
4832
  async disconnect() {
4833
+ try {
4834
+ await this.client.call("client.goodbye", {
4835
+ clientId: this.clientId,
4836
+ timestamp: Date.now()
4837
+ });
4838
+ } catch (error) {
4839
+ }
4823
4840
  this.client.disconnect();
4824
4841
  }
4825
4842
  // --- Event Handling ---
@@ -4845,6 +4862,7 @@ import { ${allImports} } from "./sandbox-tools";
4845
4862
  setupToolInvokeHandler() {
4846
4863
  this.client.registerRpcHandler("tool.invoke", async (params) => {
4847
4864
  const { callId, toolName, input } = params;
4865
+ this.emit("effect:invoke", { callId, effectKey: toolName, toolName, input });
4848
4866
  this.emit("tool:invoke", { callId, toolName, input });
4849
4867
  const handler = this.toolHandlers.get(toolName);
4850
4868
  if (!handler) {
@@ -4856,12 +4874,14 @@ import { ${allImports} } from "./sandbox-tools";
4856
4874
  }
4857
4875
  try {
4858
4876
  let result;
4877
+ const invocationContext = this.buildLegacyEffectContext();
4859
4878
  if (this.instanceTools.has(toolName) && input && typeof input === "object" && "_objectId" in input) {
4860
4879
  const { _objectId, ...restParams } = input;
4861
- result = await handler(_objectId, restParams);
4880
+ result = await handler(_objectId, restParams, invocationContext);
4862
4881
  } else {
4863
- result = await handler(input);
4882
+ result = await handler(input, invocationContext);
4864
4883
  }
4884
+ this.emit("effect:result", { callId, effectKey: toolName, result });
4865
4885
  this.emit("tool:result", { callId, result });
4866
4886
  await this.client.call("tool.result", {
4867
4887
  callId,
@@ -4869,6 +4889,7 @@ import { ${allImports} } from "./sandbox-tools";
4869
4889
  });
4870
4890
  } catch (error) {
4871
4891
  const errorMessage = error instanceof Error ? error.message : String(error);
4892
+ this.emit("effect:result", { callId, effectKey: toolName, error: errorMessage });
4872
4893
  this.emit("tool:result", { callId, error: errorMessage });
4873
4894
  await this.client.call("tool.result", {
4874
4895
  callId,
@@ -4901,10 +4922,10 @@ import { ${allImports} } from "./sandbox-tools";
4901
4922
  }
4902
4923
  }
4903
4924
  /**
4904
- * Check for changes in the tool catalog and emit 'tools:changed' if needed
4925
+ * Check for changes in the effect catalog and emit change events if needed.
4905
4926
  */
4906
4927
  checkForToolChanges() {
4907
- const currentTools = this.getTools();
4928
+ const currentTools = this.getEffects();
4908
4929
  const currentMap = new Map(currentTools.map((t) => [t.name, t]));
4909
4930
  const added = [];
4910
4931
  const removed = [];
@@ -4920,6 +4941,12 @@ import { ${allImports} } from "./sandbox-tools";
4920
4941
  }
4921
4942
  if (added.length > 0 || removed.length > 0) {
4922
4943
  this.lastKnownTools = currentMap;
4944
+ this.emit("effects:changed", {
4945
+ effects: currentTools,
4946
+ tools: currentTools,
4947
+ added,
4948
+ removed
4949
+ });
4923
4950
  this.emit("tools:changed", {
4924
4951
  tools: currentTools,
4925
4952
  added,
@@ -5030,6 +5057,50 @@ var JobImplementation = class {
5030
5057
  }
5031
5058
  };
5032
5059
 
5060
+ // src/endpoints.ts
5061
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5062
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5063
+ function readEnv(name) {
5064
+ if (typeof process === "undefined" || !process.env) return void 0;
5065
+ return process.env[name];
5066
+ }
5067
+ function normalizeMode(value) {
5068
+ if (!value) return void 0;
5069
+ const normalized = value.trim().toLowerCase();
5070
+ if (normalized === "local") return "local";
5071
+ if (normalized === "prod" || normalized === "production") return "production";
5072
+ if (normalized === "auto") return "auto";
5073
+ return void 0;
5074
+ }
5075
+ function isTruthy(value) {
5076
+ if (!value) return false;
5077
+ const normalized = value.trim().toLowerCase();
5078
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5079
+ }
5080
+ function resolveEndpointMode(explicitMode) {
5081
+ const explicit = normalizeMode(explicitMode);
5082
+ if (explicit === "local" || explicit === "production") {
5083
+ return explicit;
5084
+ }
5085
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5086
+ if (envMode === "local" || envMode === "production") {
5087
+ return envMode;
5088
+ }
5089
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5090
+ return "local";
5091
+ }
5092
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5093
+ return "production";
5094
+ }
5095
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5096
+ }
5097
+ function resolveApiUrl(explicitApiUrl, mode) {
5098
+ if (explicitApiUrl) {
5099
+ return explicitApiUrl;
5100
+ }
5101
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5102
+ }
5103
+
5033
5104
  // src/client.ts
5034
5105
  var STANDARD_MODULES_OPERATIONS = [
5035
5106
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -5044,6 +5115,37 @@ var STANDARD_MODULES_OPERATIONS = [
5044
5115
  var BUILTIN_MODULES = {
5045
5116
  "standard_modules": STANDARD_MODULES_OPERATIONS
5046
5117
  };
5118
+ function computeEffectKey(effect) {
5119
+ const attachedClass = effect.className?.trim();
5120
+ if (!attachedClass) {
5121
+ return `global:${effect.name}`;
5122
+ }
5123
+ return effect.static ? `class:${attachedClass}:static:${effect.name}` : `class:${attachedClass}:instance:${effect.name}`;
5124
+ }
5125
+ function buildEffectHostUrl(apiUrl, sandboxId, effectClientId, clientId) {
5126
+ const url = new URL(apiUrl);
5127
+ if (url.pathname.endsWith("/granular/ws/connect")) {
5128
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5129
+ } else if (url.pathname.endsWith("/granular")) {
5130
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/effects/connect`;
5131
+ } else if (url.pathname.endsWith("/v2/ws/connect")) {
5132
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5133
+ } else if (url.pathname.endsWith("/v2/ws")) {
5134
+ url.pathname = url.pathname.replace(/\/ws$/, "/effects/connect");
5135
+ } else if (url.pathname.endsWith("/ws/connect")) {
5136
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5137
+ } else if (url.pathname.endsWith("/ws")) {
5138
+ url.pathname = url.pathname.replace(/\/ws$/, "/effects/connect");
5139
+ } else {
5140
+ url.pathname = "/granular/effects/connect";
5141
+ }
5142
+ url.search = "";
5143
+ url.hash = "";
5144
+ url.searchParams.set("sandboxId", sandboxId);
5145
+ url.searchParams.set("effectClientId", effectClientId);
5146
+ url.searchParams.set("clientId", clientId);
5147
+ return url.toString();
5148
+ }
5047
5149
  var Environment = class _Environment extends Session {
5048
5150
  envData;
5049
5151
  _apiKey;
@@ -5074,6 +5176,60 @@ var Environment = class _Environment extends Session {
5074
5176
  get apiEndpoint() {
5075
5177
  return this._apiEndpoint;
5076
5178
  }
5179
+ getRuntimeBaseUrl() {
5180
+ try {
5181
+ const endpoint = new URL(this._apiEndpoint);
5182
+ const graphqlSuffix = "/orchestrator/graphql";
5183
+ if (endpoint.pathname.endsWith(graphqlSuffix)) {
5184
+ endpoint.pathname = endpoint.pathname.slice(0, -graphqlSuffix.length);
5185
+ } else if (endpoint.pathname.endsWith("/graphql")) {
5186
+ endpoint.pathname = endpoint.pathname.slice(0, -"/graphql".length);
5187
+ }
5188
+ endpoint.search = "";
5189
+ endpoint.hash = "";
5190
+ return endpoint.toString().replace(/\/$/, "");
5191
+ } catch {
5192
+ return this._apiEndpoint.replace(/\/orchestrator\/graphql$/, "").replace(/\/$/, "");
5193
+ }
5194
+ }
5195
+ /**
5196
+ * Close the session and disconnect from the sandbox.
5197
+ *
5198
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
5199
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
5200
+ * acknowledgement was observed.
5201
+ */
5202
+ async disconnect() {
5203
+ let wsNotifiedRuntime = false;
5204
+ try {
5205
+ const goodbye = await this.rpc("client.goodbye", {
5206
+ timestamp: Date.now()
5207
+ });
5208
+ wsNotifiedRuntime = Boolean(goodbye?.ok && goodbye?.via);
5209
+ } catch {
5210
+ wsNotifiedRuntime = false;
5211
+ }
5212
+ if (!wsNotifiedRuntime) {
5213
+ try {
5214
+ const runtimeBase = this.getRuntimeBaseUrl();
5215
+ await fetch(
5216
+ `${runtimeBase}/orchestrator/runtime/environments/${this.environmentId}/session-goodbye`,
5217
+ {
5218
+ method: "POST",
5219
+ headers: {
5220
+ "Content-Type": "application/json",
5221
+ "Authorization": `Bearer ${this._apiKey}`
5222
+ },
5223
+ body: JSON.stringify({
5224
+ reason: "sdk_disconnect_http_fallback"
5225
+ })
5226
+ }
5227
+ );
5228
+ } catch {
5229
+ }
5230
+ }
5231
+ this.client.disconnect();
5232
+ }
5077
5233
  // ==================== GRAPH CONTAINER READINESS ====================
5078
5234
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
5079
5235
  graphContainerStatus = null;
@@ -5734,72 +5890,31 @@ var Environment = class _Environment extends Session {
5734
5890
  }
5735
5891
  // ==================== PUBLISH TOOLS ====================
5736
5892
  /**
5737
- * Convenience method: publish tools and get back wrapped result.
5738
- * This is the main entry point for setting up tools.
5739
- *
5740
- * @example
5741
- * ```typescript
5742
- * const tools = [
5743
- * {
5744
- * name: 'get_weather',
5745
- * description: 'Get weather for a city',
5746
- * inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
5747
- * handler: async ({ city }) => ({ temp: 22 }),
5748
- * },
5749
- * ];
5750
- *
5751
- * const { domainRevision } = await environment.publishTools(tools);
5752
- *
5753
- * // Now submit jobs that use those tools
5754
- * const job = await environment.submitJob(`
5755
- * import { Author } from './sandbox-tools';
5756
- * const weather = await Author.get_weather({ city: 'Paris' });
5757
- * return weather;
5758
- * `);
5759
- *
5760
- * const result = await job.result;
5761
- * ```
5893
+ * Removed: environment-scoped effect publication is no longer supported.
5762
5894
  */
5763
5895
  async publishTools(tools, revision = "1.0.0") {
5764
5896
  return super.publishTools(tools, revision);
5765
5897
  }
5766
5898
  /**
5767
- * Publish a single effect (tool) incrementally.
5768
- *
5769
- * Adds the effect to the local registry and re-publishes the
5770
- * full tool catalog to the server. If an effect with the same
5771
- * name already exists, it is replaced.
5772
- *
5773
- * @example
5774
- * ```typescript
5775
- * await env.publishEffect({
5776
- * name: 'get_bio',
5777
- * description: 'Get biography',
5778
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
5779
- * handler: async (params) => ({ bio: 'Hello' }),
5780
- * });
5781
- * ```
5899
+ * Removed: environment-scoped effect publication is no longer supported.
5782
5900
  */
5783
5901
  async publishEffect(effect) {
5784
5902
  return super.publishEffect(effect);
5785
5903
  }
5786
5904
  /**
5787
- * Publish multiple effects (tools) at once.
5788
- *
5789
- * Adds all effects to the local registry and re-publishes the
5790
- * full tool catalog in a single RPC call.
5905
+ * Removed: environment-scoped effect publication is no longer supported.
5791
5906
  */
5792
5907
  async publishEffects(effects) {
5793
5908
  return super.publishEffects(effects);
5794
5909
  }
5795
5910
  /**
5796
- * Remove an effect by name and re-publish the remaining catalog.
5911
+ * Removed: environment-scoped effect publication is no longer supported.
5797
5912
  */
5798
5913
  async unpublishEffect(name) {
5799
5914
  return super.unpublishEffect(name);
5800
5915
  }
5801
5916
  /**
5802
- * Remove all effects and publish an empty catalog.
5917
+ * Removed: environment-scoped effect publication is no longer supported.
5803
5918
  */
5804
5919
  async unpublishAllEffects() {
5805
5920
  return super.unpublishAllEffects();
@@ -5809,13 +5924,16 @@ var Granular = class {
5809
5924
  apiKey;
5810
5925
  apiUrl;
5811
5926
  httpUrl;
5927
+ tokenProvider;
5812
5928
  WebSocketCtor;
5813
5929
  onUnexpectedClose;
5814
5930
  onReconnectError;
5815
- /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
5931
+ /** Sandbox-level effect registry: sandboxId → (effectKey → ToolWithHandler) */
5816
5932
  sandboxEffects = /* @__PURE__ */ new Map();
5817
- /** Active environments tracker: sandboxId Environment[] */
5818
- activeEnvironments = /* @__PURE__ */ new Map();
5933
+ /** Live sandbox-scoped effect hosts keyed by sandboxId */
5934
+ sandboxEffectHosts = /* @__PURE__ */ new Map();
5935
+ /** In-flight host connection promises to avoid duplicate concurrent connects */
5936
+ sandboxEffectHostPromises = /* @__PURE__ */ new Map();
5819
5937
  /**
5820
5938
  * Create a new Granular client
5821
5939
  * @param options - Client configuration
@@ -5826,11 +5944,12 @@ var Granular = class {
5826
5944
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5827
5945
  }
5828
5946
  this.apiKey = auth;
5829
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
5947
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
5948
+ this.tokenProvider = options.tokenProvider;
5830
5949
  this.WebSocketCtor = options.WebSocketCtor;
5831
5950
  this.onUnexpectedClose = options.onUnexpectedClose;
5832
5951
  this.onReconnectError = options.onReconnectError;
5833
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
5952
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5834
5953
  }
5835
5954
  /**
5836
5955
  * Records/upserts a user and prepares them for sandbox connections
@@ -5867,8 +5986,9 @@ var Granular = class {
5867
5986
  /**
5868
5987
  * Connect to a sandbox and establish a real-time environment session.
5869
5988
  *
5870
- * After connecting, use `environment.publishTools()` to register tools,
5871
- * then `environment.submitJob()` to execute code that uses those tools.
5989
+ * Effects are registered at the sandbox level via `granular.registerEffect()`
5990
+ * or `granular.registerEffects()`. Sessions pick up live availability from
5991
+ * the sandbox registry automatically.
5872
5992
  *
5873
5993
  * @param options - Connection options
5874
5994
  * @returns An active environment session
@@ -5885,10 +6005,12 @@ var Granular = class {
5885
6005
  * user,
5886
6006
  * });
5887
6007
  *
5888
- * // Publish tools
5889
- * await environment.publishTools([
5890
- * { name: 'greet', description: 'Say hello', inputSchema: {}, handler: async () => 'Hello!' },
5891
- * ]);
6008
+ * await granular.registerEffect('my-sandbox', {
6009
+ * name: 'greet',
6010
+ * description: 'Say hello',
6011
+ * inputSchema: { type: 'object', properties: {} },
6012
+ * handler: async () => 'Hello!',
6013
+ * });
5892
6014
  *
5893
6015
  * // Submit job
5894
6016
  * const job = await environment.submitJob(`
@@ -5910,10 +6032,19 @@ var Granular = class {
5910
6032
  subjectId: options.user.subjectId,
5911
6033
  permissionProfileId: null
5912
6034
  });
6035
+ await this.activateEnvironment(envData.environmentId);
6036
+ const session = await this.request("/ws/sessions", {
6037
+ method: "POST",
6038
+ body: JSON.stringify({
6039
+ environmentId: envData.environmentId,
6040
+ clientId
6041
+ })
6042
+ });
5913
6043
  const client = new WSClient({
5914
- url: this.apiUrl,
5915
- sessionId: envData.environmentId,
5916
- token: this.apiKey,
6044
+ url: session.wsUrl,
6045
+ sessionId: session.sessionId,
6046
+ token: session.token,
6047
+ tokenProvider: this.tokenProvider,
5917
6048
  WebSocketCtor: this.WebSocketCtor,
5918
6049
  onUnexpectedClose: this.onUnexpectedClose,
5919
6050
  onReconnectError: this.onReconnectError
@@ -5921,47 +6052,165 @@ var Granular = class {
5921
6052
  await client.connect();
5922
6053
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;
5923
6054
  const environment = new Environment(client, envData, clientId, this.apiKey, graphqlEndpoint);
5924
- if (!this.activeEnvironments.has(sandbox.sandboxId)) {
5925
- this.activeEnvironments.set(sandbox.sandboxId, []);
5926
- }
5927
- this.activeEnvironments.get(sandbox.sandboxId).push(environment);
5928
- environment.on("disconnect", () => {
5929
- const list = this.activeEnvironments.get(sandbox.sandboxId);
5930
- if (list) {
5931
- this.activeEnvironments.set(sandbox.sandboxId, list.filter((e) => e !== environment));
5932
- }
5933
- });
5934
6055
  await environment.hello();
5935
- const effects = this.sandboxEffects.get(sandbox.sandboxId);
5936
- if (effects && effects.size > 0) {
5937
- const effectsList = Array.from(effects.values());
5938
- console.log(`[Granular] Auto-publishing ${effectsList.length} effects for sandbox ${sandbox.sandboxId}`);
5939
- await environment.publishEffects(effectsList);
5940
- }
5941
6056
  return environment;
5942
6057
  }
6058
+ async activateEnvironment(environmentId) {
6059
+ await this.request(`/orchestrator/runtime/environments/${environmentId}/activate`, {
6060
+ method: "POST",
6061
+ body: JSON.stringify({})
6062
+ });
6063
+ }
5943
6064
  // ── Sandbox-Level Effects ──
6065
+ getSandboxEffectMap(sandboxId) {
6066
+ let effects = this.sandboxEffects.get(sandboxId);
6067
+ if (!effects) {
6068
+ effects = /* @__PURE__ */ new Map();
6069
+ this.sandboxEffects.set(sandboxId, effects);
6070
+ }
6071
+ return effects;
6072
+ }
6073
+ serializeEffect(effect) {
6074
+ return {
6075
+ effectKey: computeEffectKey(effect),
6076
+ name: effect.name,
6077
+ description: effect.description,
6078
+ inputSchema: effect.inputSchema,
6079
+ outputSchema: effect.outputSchema,
6080
+ stability: effect.stability || "stable",
6081
+ provenance: effect.provenance || { source: "custom" },
6082
+ tags: effect.tags,
6083
+ className: effect.className,
6084
+ static: effect.static
6085
+ };
6086
+ }
6087
+ async publishSandboxEffectCatalog(host) {
6088
+ const effects = Array.from(this.getSandboxEffectMap(host.sandboxId).values()).map(
6089
+ (effect) => this.serializeEffect(effect)
6090
+ );
6091
+ await host.wsClient.call("effects.publishCatalog", { effects });
6092
+ }
6093
+ async syncSandboxEffectCatalog(sandboxId) {
6094
+ const host = await this.ensureSandboxEffectHost(sandboxId);
6095
+ await this.publishSandboxEffectCatalog(host);
6096
+ }
6097
+ startEffectHostHeartbeat(host) {
6098
+ if (host.heartbeatTimer) {
6099
+ clearInterval(host.heartbeatTimer);
6100
+ }
6101
+ host.wsClient.call("client.heartbeat", {}).catch((error) => {
6102
+ console.warn(
6103
+ `[Granular] Initial effect host heartbeat failed for sandbox ${host.sandboxId}:`,
6104
+ error
6105
+ );
6106
+ });
6107
+ host.heartbeatTimer = setInterval(() => {
6108
+ host.wsClient.call("client.heartbeat", {}).catch((error) => {
6109
+ console.warn(
6110
+ `[Granular] Effect host heartbeat failed for sandbox ${host.sandboxId}:`,
6111
+ error
6112
+ );
6113
+ });
6114
+ }, 1e4);
6115
+ }
6116
+ stopEffectHostHeartbeat(host) {
6117
+ if (!host?.heartbeatTimer) {
6118
+ return;
6119
+ }
6120
+ clearInterval(host.heartbeatTimer);
6121
+ host.heartbeatTimer = null;
6122
+ }
6123
+ async synchronizeEffectHost(host) {
6124
+ await host.wsClient.call("client.hello", {
6125
+ clientId: host.clientId,
6126
+ protocolVersion: "2.0"
6127
+ });
6128
+ this.startEffectHostHeartbeat(host);
6129
+ await this.publishSandboxEffectCatalog(host);
6130
+ }
6131
+ async ensureSandboxEffectHost(sandboxId) {
6132
+ const existing = this.sandboxEffectHosts.get(sandboxId);
6133
+ if (existing) {
6134
+ return existing;
6135
+ }
6136
+ const inflight = this.sandboxEffectHostPromises.get(sandboxId);
6137
+ if (inflight) {
6138
+ return inflight;
6139
+ }
6140
+ const connectPromise = (async () => {
6141
+ const effectClientId = crypto.randomUUID();
6142
+ const clientId = `effect-host:${sandboxId}:${effectClientId}`;
6143
+ const wsClient = new WSClient({
6144
+ url: buildEffectHostUrl(this.apiUrl, sandboxId, effectClientId, clientId),
6145
+ sessionId: `effect-host:${effectClientId}`,
6146
+ token: this.apiKey,
6147
+ tokenProvider: this.tokenProvider,
6148
+ WebSocketCtor: this.WebSocketCtor,
6149
+ onUnexpectedClose: this.onUnexpectedClose,
6150
+ onReconnectError: this.onReconnectError
6151
+ });
6152
+ const host = {
6153
+ sandboxId,
6154
+ effectClientId,
6155
+ clientId,
6156
+ wsClient,
6157
+ heartbeatTimer: null
6158
+ };
6159
+ wsClient.registerRpcHandler("effect.invoke", async (params) => {
6160
+ const request = params;
6161
+ const effect = this.getSandboxEffectMap(sandboxId).get(request.effectKey);
6162
+ if (!effect) {
6163
+ throw new Error(`Effect handler not found: ${request.effectKey}`);
6164
+ }
6165
+ if (effect.className && !effect.static && request.input && typeof request.input === "object" && "_objectId" in request.input) {
6166
+ const { _objectId, ...rest } = request.input;
6167
+ return effect.handler(_objectId, rest, request.context);
6168
+ }
6169
+ return effect.handler(request.input, request.context);
6170
+ });
6171
+ wsClient.on("open", () => {
6172
+ void this.synchronizeEffectHost(host).catch((error) => {
6173
+ console.error(
6174
+ `[Granular] Failed to re-sync effect host for sandbox ${sandboxId}:`,
6175
+ error
6176
+ );
6177
+ });
6178
+ });
6179
+ wsClient.on("disconnect", () => {
6180
+ this.stopEffectHostHeartbeat(host);
6181
+ });
6182
+ await wsClient.connect();
6183
+ await this.synchronizeEffectHost(host);
6184
+ this.sandboxEffectHosts.set(sandboxId, host);
6185
+ return host;
6186
+ })();
6187
+ this.sandboxEffectHostPromises.set(sandboxId, connectPromise);
6188
+ try {
6189
+ return await connectPromise;
6190
+ } finally {
6191
+ this.sandboxEffectHostPromises.delete(sandboxId);
6192
+ }
6193
+ }
6194
+ disconnectSandboxEffectHost(sandboxId) {
6195
+ const host = this.sandboxEffectHosts.get(sandboxId);
6196
+ if (!host) {
6197
+ return;
6198
+ }
6199
+ this.stopEffectHostHeartbeat(host);
6200
+ host.wsClient.disconnect();
6201
+ this.sandboxEffectHosts.delete(sandboxId);
6202
+ }
5944
6203
  /**
5945
6204
  * Register an effect (tool) for a specific sandbox.
5946
- *
5947
- * The effect will be automatically published to:
5948
- * 1. Any currently active environments for this sandbox
5949
- * 2. Any new environments created/connected for this sandbox
5950
- *
6205
+ *
5951
6206
  * @param sandboxNameOrId - The name or ID of the sandbox
5952
6207
  * @param effect - The tool definition and handler
5953
6208
  */
5954
6209
  async registerEffect(sandboxNameOrId, effect) {
5955
6210
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
5956
6211
  const sandboxId = sandbox.sandboxId;
5957
- if (!this.sandboxEffects.has(sandboxId)) {
5958
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
5959
- }
5960
- this.sandboxEffects.get(sandboxId).set(effect.name, effect);
5961
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
5962
- for (const env of activeEnvs) {
5963
- await env.publishEffect(effect);
5964
- }
6212
+ this.getSandboxEffectMap(sandboxId).set(computeEffectKey(effect), effect);
6213
+ await this.syncSandboxEffectCatalog(sandboxId);
5965
6214
  }
5966
6215
  /**
5967
6216
  * Register multiple effects (tools) for a specific sandbox.
@@ -5971,35 +6220,39 @@ var Granular = class {
5971
6220
  async registerEffects(sandboxNameOrId, effects) {
5972
6221
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
5973
6222
  const sandboxId = sandbox.sandboxId;
5974
- if (!this.sandboxEffects.has(sandboxId)) {
5975
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
5976
- }
5977
- const map = this.sandboxEffects.get(sandboxId);
6223
+ const map = this.getSandboxEffectMap(sandboxId);
5978
6224
  for (const effect of effects) {
5979
- map.set(effect.name, effect);
5980
- }
5981
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
5982
- for (const env of activeEnvs) {
5983
- await env.publishEffects(effects);
6225
+ map.set(computeEffectKey(effect), effect);
5984
6226
  }
6227
+ await this.syncSandboxEffectCatalog(sandboxId);
5985
6228
  }
5986
6229
  /**
5987
6230
  * Unregister an effect from a sandbox.
5988
6231
  *
5989
- * Removes it from the local registry and unpublishes it from
5990
- * all active environments.
6232
+ * Removes it from the local sandbox registry and updates the
6233
+ * sandbox-scoped live catalog.
5991
6234
  */
5992
6235
  async unregisterEffect(sandboxNameOrId, name) {
5993
6236
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
5994
6237
  const sandboxId = sandbox.sandboxId;
5995
- const map = this.sandboxEffects.get(sandboxId);
5996
- if (map) {
5997
- map.delete(name);
6238
+ const currentMap = this.sandboxEffects.get(sandboxId);
6239
+ if (!currentMap) {
6240
+ return;
5998
6241
  }
5999
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6000
- for (const env of activeEnvs) {
6001
- await env.unpublishEffect(name);
6242
+ const nextEntries = Array.from(currentMap.entries()).filter(
6243
+ ([effectKey, effect]) => effectKey !== name && effect.name !== name
6244
+ );
6245
+ if (nextEntries.length === currentMap.size) {
6246
+ return;
6002
6247
  }
6248
+ const nextMap = new Map(nextEntries);
6249
+ this.sandboxEffects.set(sandboxId, nextMap);
6250
+ if (nextMap.size === 0) {
6251
+ this.disconnectSandboxEffectHost(sandboxId);
6252
+ this.sandboxEffects.delete(sandboxId);
6253
+ return;
6254
+ }
6255
+ await this.syncSandboxEffectCatalog(sandboxId);
6003
6256
  }
6004
6257
  /**
6005
6258
  * Unregister all effects for a sandbox.
@@ -6008,10 +6261,7 @@ var Granular = class {
6008
6261
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6009
6262
  const sandboxId = sandbox.sandboxId;
6010
6263
  this.sandboxEffects.delete(sandboxId);
6011
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6012
- for (const env of activeEnvs) {
6013
- await env.unpublishAllEffects();
6014
- }
6264
+ this.disconnectSandboxEffectHost(sandboxId);
6015
6265
  }
6016
6266
  /**
6017
6267
  * Find a sandbox by name or create it if it doesn't exist
@@ -6047,7 +6297,7 @@ var Granular = class {
6047
6297
  const created = await this.permissionProfiles.create(sandboxId, {
6048
6298
  name: profileName,
6049
6299
  rules: {
6050
- tools: { allow: ["*"] },
6300
+ effects: { allow: ["*"] },
6051
6301
  resources: { allow: ["*"] }
6052
6302
  }
6053
6303
  });