@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.js CHANGED
@@ -3939,6 +3939,9 @@ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
3939
3939
  GlobalWebSocket = globalThis.WebSocket;
3940
3940
  }
3941
3941
  var READY_STATE_OPEN = 1;
3942
+ var TOKEN_REFRESH_LEEWAY_MS = 2 * 60 * 1e3;
3943
+ var TOKEN_REFRESH_RETRY_MS = 30 * 1e3;
3944
+ var MAX_TIMER_DELAY_MS = 2147483647;
3942
3945
  var WSClient = class {
3943
3946
  ws = null;
3944
3947
  url;
@@ -3952,6 +3955,7 @@ var WSClient = class {
3952
3955
  doc = Automerge__namespace.init();
3953
3956
  syncState = Automerge__namespace.initSyncState();
3954
3957
  reconnectTimer = null;
3958
+ tokenRefreshTimer = null;
3955
3959
  isExplicitlyDisconnected = false;
3956
3960
  options;
3957
3961
  constructor(options) {
@@ -3960,12 +3964,109 @@ var WSClient = class {
3960
3964
  this.sessionId = options.sessionId;
3961
3965
  this.token = options.token;
3962
3966
  }
3967
+ clearTokenRefreshTimer() {
3968
+ if (this.tokenRefreshTimer) {
3969
+ clearTimeout(this.tokenRefreshTimer);
3970
+ this.tokenRefreshTimer = null;
3971
+ }
3972
+ }
3973
+ decodeBase64Url(payload) {
3974
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
3975
+ const padded = base64 + "=".repeat((4 - (base64.length % 4 || 4)) % 4);
3976
+ if (typeof atob === "function") {
3977
+ return atob(padded);
3978
+ }
3979
+ const maybeBuffer = globalThis.Buffer;
3980
+ if (maybeBuffer) {
3981
+ return maybeBuffer.from(padded, "base64").toString("utf8");
3982
+ }
3983
+ throw new Error("No base64 decoder available");
3984
+ }
3985
+ getTokenExpiryMs(token) {
3986
+ const parts = token.split(".");
3987
+ if (parts.length < 2) {
3988
+ return null;
3989
+ }
3990
+ try {
3991
+ const payloadRaw = this.decodeBase64Url(parts[1]);
3992
+ const payload = JSON.parse(payloadRaw);
3993
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
3994
+ return null;
3995
+ }
3996
+ return payload.exp * 1e3;
3997
+ } catch {
3998
+ return null;
3999
+ }
4000
+ }
4001
+ scheduleTokenRefresh() {
4002
+ this.clearTokenRefreshTimer();
4003
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
4004
+ return;
4005
+ }
4006
+ const expiresAt = this.getTokenExpiryMs(this.token);
4007
+ if (!expiresAt) {
4008
+ return;
4009
+ }
4010
+ const refreshInMs = Math.max(1e3, expiresAt - Date.now() - TOKEN_REFRESH_LEEWAY_MS);
4011
+ const delay = Math.min(refreshInMs, MAX_TIMER_DELAY_MS);
4012
+ this.tokenRefreshTimer = setTimeout(() => {
4013
+ void this.refreshTokenInBackground();
4014
+ }, delay);
4015
+ }
4016
+ async refreshTokenInBackground() {
4017
+ if (!this.options.tokenProvider || this.isExplicitlyDisconnected) {
4018
+ return;
4019
+ }
4020
+ try {
4021
+ const refreshedToken = await this.options.tokenProvider();
4022
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4023
+ throw new Error("Token provider returned no token");
4024
+ }
4025
+ this.token = refreshedToken;
4026
+ this.scheduleTokenRefresh();
4027
+ } catch (error) {
4028
+ if (this.isExplicitlyDisconnected) {
4029
+ return;
4030
+ }
4031
+ console.warn("[Granular] Token refresh failed, retrying soon:", error);
4032
+ this.clearTokenRefreshTimer();
4033
+ this.tokenRefreshTimer = setTimeout(() => {
4034
+ void this.refreshTokenInBackground();
4035
+ }, TOKEN_REFRESH_RETRY_MS);
4036
+ }
4037
+ }
4038
+ async resolveTokenForConnect() {
4039
+ if (!this.options.tokenProvider) {
4040
+ return this.token;
4041
+ }
4042
+ const expiresAt = this.getTokenExpiryMs(this.token);
4043
+ const shouldRefresh = expiresAt !== null && expiresAt - Date.now() <= TOKEN_REFRESH_LEEWAY_MS;
4044
+ if (!shouldRefresh) {
4045
+ return this.token;
4046
+ }
4047
+ try {
4048
+ const refreshedToken = await this.options.tokenProvider();
4049
+ if (typeof refreshedToken !== "string" || refreshedToken.length === 0) {
4050
+ throw new Error("Token provider returned no token");
4051
+ }
4052
+ this.token = refreshedToken;
4053
+ return refreshedToken;
4054
+ } catch (error) {
4055
+ if (expiresAt > Date.now()) {
4056
+ console.warn("[Granular] Token refresh failed, using current token:", error);
4057
+ return this.token;
4058
+ }
4059
+ throw error;
4060
+ }
4061
+ }
3963
4062
  /**
3964
4063
  * Connect to the WebSocket server
3965
4064
  * @returns {Promise<void>} Resolves when connection is open
3966
4065
  */
3967
4066
  async connect() {
4067
+ const token = await this.resolveTokenForConnect();
3968
4068
  this.isExplicitlyDisconnected = false;
4069
+ this.scheduleTokenRefresh();
3969
4070
  if (this.reconnectTimer) {
3970
4071
  clearTimeout(this.reconnectTimer);
3971
4072
  this.reconnectTimer = null;
@@ -3985,7 +4086,7 @@ var WSClient = class {
3985
4086
  try {
3986
4087
  const wsUrl = new URL(this.url);
3987
4088
  wsUrl.searchParams.set("sessionId", this.sessionId);
3988
- wsUrl.searchParams.set("token", this.token);
4089
+ wsUrl.searchParams.set("token", token);
3989
4090
  this.ws = new WebSocketClass(wsUrl.toString());
3990
4091
  if (!this.ws) throw new Error("Failed to create WebSocket");
3991
4092
  const socket = this.ws;
@@ -4190,10 +4291,10 @@ var WSClient = class {
4190
4291
  const snapshotMessage = message;
4191
4292
  try {
4192
4293
  const bytes = new Uint8Array(snapshotMessage.data);
4193
- console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
4294
+ console.log("[Granular DEBUG] Loading Automerge session snapshot bytes:", bytes.length);
4194
4295
  this.doc = Automerge__namespace.load(bytes);
4195
4296
  this.emit("sync", this.doc);
4196
- console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
4297
+ console.log("[Granular DEBUG] Automerge session snapshot loaded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
4197
4298
  } catch (e) {
4198
4299
  console.warn("[Granular] Failed to load snapshot message", e);
4199
4300
  }
@@ -4356,6 +4457,7 @@ var WSClient = class {
4356
4457
  clearTimeout(this.reconnectTimer);
4357
4458
  this.reconnectTimer = null;
4358
4459
  }
4460
+ this.clearTokenRefreshTimer();
4359
4461
  if (this.ws) {
4360
4462
  this.ws.close(1e3, "Client disconnect");
4361
4463
  this.ws = null;
@@ -4386,6 +4488,17 @@ var Session = class {
4386
4488
  this.setupEventHandlers();
4387
4489
  this.setupToolInvokeHandler();
4388
4490
  }
4491
+ buildLegacyEffectContext() {
4492
+ return {
4493
+ effectClientId: this.clientId,
4494
+ sandboxId: "",
4495
+ environmentId: "",
4496
+ sessionId: "",
4497
+ user: {
4498
+ subjectId: ""
4499
+ }
4500
+ };
4501
+ }
4389
4502
  // --- Public API ---
4390
4503
  get document() {
4391
4504
  return this.client.doc;
@@ -4424,155 +4537,30 @@ var Session = class {
4424
4537
  });
4425
4538
  return result;
4426
4539
  }
4427
- /**
4428
- * Publish tools to the sandbox and register handlers for reverse-RPC.
4429
- *
4430
- * Tools can be:
4431
- * - **Instance methods**: set `className` (handler receives `(objectId, params)`)
4432
- * - **Static methods**: set `className` + `static: true` (handler receives `(params)`)
4433
- * - **Global tools**: omit `className` (handler receives `(params)`)
4434
- *
4435
- * Both `inputSchema` and `outputSchema` accept JSON Schema objects. The
4436
- * `outputSchema` drives the return type in the auto-generated TypeScript
4437
- * class declarations that sandbox code imports from `./sandbox-tools`.
4438
- *
4439
- * This method:
4440
- * 1. Extracts tool schemas (including `outputSchema`) from the provided tools
4441
- * 2. Publishes them via `client.publishRawToolCatalog` RPC
4442
- * 3. Registers handlers locally for `tool.invoke` RPC calls
4443
- * 4. Returns the `domainRevision` needed for job submission
4444
- *
4445
- * @param tools - Array of tools with handlers
4446
- * @param revision - Optional revision string (default: "1.0.0")
4447
- * @returns PublishToolsResult with domainRevision
4448
- *
4449
- * @example
4450
- * ```typescript
4451
- * await env.publishTools([
4452
- * {
4453
- * name: 'get_bio',
4454
- * description: 'Get biography of an author',
4455
- * className: 'author',
4456
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4457
- * outputSchema: { type: 'object', properties: { bio: { type: 'string' } }, required: ['bio'] },
4458
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4459
- * },
4460
- * ]);
4461
- * ```
4462
- */
4463
4540
  async publishTools(tools, revision = "1.0.0") {
4464
- const schemas = tools.map((tool) => ({
4465
- name: tool.name,
4466
- description: tool.description,
4467
- inputSchema: tool.inputSchema,
4468
- outputSchema: tool.outputSchema,
4469
- stability: tool.stability || "stable",
4470
- provenance: tool.provenance || { source: "mcp" },
4471
- tags: tool.tags,
4472
- className: tool.className,
4473
- static: tool.static
4474
- }));
4475
- const result = await this.client.call("client.publishRawToolCatalog", {
4476
- clientId: this.clientId,
4477
- revision,
4478
- tools: schemas
4479
- });
4480
- if (!result.accepted || !result.domainRevision) {
4481
- throw new Error(`Failed to publish tools: ${JSON.stringify(result.rejected)}`);
4482
- }
4483
- for (const tool of tools) {
4484
- this.toolHandlers.set(tool.name, tool.handler);
4485
- if (tool.className && !tool.static) {
4486
- this.instanceTools.add(tool.name);
4487
- } else {
4488
- this.instanceTools.delete(tool.name);
4489
- }
4490
- }
4491
- this.currentDomainRevision = result.domainRevision;
4492
- return {
4493
- accepted: result.accepted,
4494
- domainRevision: result.domainRevision,
4495
- rejected: result.rejected
4496
- };
4541
+ throw new Error(
4542
+ "Environment-scoped effect publication was removed. Declare effects in the manifest and register live handlers with granular.registerEffects(environment.sandboxId, effects)."
4543
+ );
4497
4544
  }
4498
- /**
4499
- * Publish a single effect (tool) to the sandbox.
4500
- *
4501
- * Adds the effect to the local registry and re-publishes the
4502
- * full tool catalog to the server. If an effect with the same
4503
- * name already exists, it is replaced.
4504
- *
4505
- * @param effect - The effect (tool) to publish
4506
- * @returns PublishToolsResult with domainRevision
4507
- *
4508
- * @example
4509
- * ```typescript
4510
- * await env.publishEffect({
4511
- * name: 'get_bio',
4512
- * description: 'Get biography of an author',
4513
- * className: 'author',
4514
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4515
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4516
- * });
4517
- * ```
4518
- */
4519
4545
  async publishEffect(effect) {
4520
- this.effects.set(effect.name, effect);
4521
- return this._syncEffects();
4546
+ throw new Error(
4547
+ "Environment-scoped effect publication was removed. Use granular.registerEffect(environment.sandboxId, effect)."
4548
+ );
4522
4549
  }
4523
- /**
4524
- * Publish multiple effects (tools) at once.
4525
- *
4526
- * Adds all effects to the local registry and re-publishes the
4527
- * full tool catalog in a single RPC call.
4528
- *
4529
- * @param effects - Array of effects to publish
4530
- * @returns PublishToolsResult with domainRevision
4531
- *
4532
- * @example
4533
- * ```typescript
4534
- * await env.publishEffects([
4535
- * { name: 'get_bio', description: '...', inputSchema: {}, handler: async (id) => ({}) },
4536
- * { name: 'search', description: '...', inputSchema: {}, handler: async (params) => ({}) },
4537
- * ]);
4538
- * ```
4539
- */
4540
4550
  async publishEffects(effects) {
4541
- for (const effect of effects) {
4542
- this.effects.set(effect.name, effect);
4543
- }
4544
- return this._syncEffects();
4551
+ throw new Error(
4552
+ "Environment-scoped effect publication was removed. Use granular.registerEffects(environment.sandboxId, effects)."
4553
+ );
4545
4554
  }
4546
- /**
4547
- * Remove an effect by name and re-publish the remaining catalog.
4548
- *
4549
- * @param name - The name of the effect to remove
4550
- * @returns PublishToolsResult with domainRevision
4551
- */
4552
4555
  async unpublishEffect(name) {
4553
- this.effects.delete(name);
4554
- this.toolHandlers.delete(name);
4555
- this.instanceTools.delete(name);
4556
- return this._syncEffects();
4556
+ throw new Error(
4557
+ "Environment-scoped effect publication was removed. Use granular.unregisterEffect(environment.sandboxId, effectName)."
4558
+ );
4557
4559
  }
4558
- /**
4559
- * Remove all effects and publish an empty catalog.
4560
- *
4561
- * @returns PublishToolsResult with domainRevision
4562
- */
4563
4560
  async unpublishAllEffects() {
4564
- this.effects.clear();
4565
- this.toolHandlers.clear();
4566
- this.instanceTools.clear();
4567
- return this._syncEffects();
4568
- }
4569
- /**
4570
- * Internal: re-publish the full effect catalog to the server.
4571
- * Called after any mutation to the effects Map.
4572
- */
4573
- async _syncEffects() {
4574
- const allEffects = Array.from(this.effects.values());
4575
- return this.publishTools(allEffects);
4561
+ throw new Error(
4562
+ "Environment-scoped effect publication was removed. Use granular.unregisterAllEffects(environment.sandboxId)."
4563
+ );
4576
4564
  }
4577
4565
  /**
4578
4566
  * Submit a job to execute code in the sandbox.
@@ -4586,14 +4574,14 @@ var Session = class {
4586
4574
  * const books = await tolkien.get_books();
4587
4575
  * ```
4588
4576
  *
4589
- * Tool calls (instance methods, static methods, global functions) trigger
4590
- * `tool.invoke` RPC back to this client, where the registered handlers
4577
+ * Effect calls (instance methods, static methods, global functions) trigger
4578
+ * `effect.invoke` RPC back to the sandbox effect host, where the registered handlers
4591
4579
  * execute locally and return the result to the sandbox.
4592
4580
  */
4593
4581
  async submitJob(code, domainRevision) {
4594
4582
  const revision = domainRevision || this.currentDomainRevision || this.client.doc?.domain?.active || void 0;
4595
4583
  if (!revision) {
4596
- throw new Error("No domain revision available. Call publishTools() or ensure schema is activated.");
4584
+ throw new Error("No domain revision available. Register live effects or ensure the build schema is activated.");
4597
4585
  }
4598
4586
  const result = await this.client.call("job.submit", {
4599
4587
  domainRevision: revision,
@@ -4625,10 +4613,10 @@ var Session = class {
4625
4613
  await this.client.call("prompt.answer", { promptId, value: answer });
4626
4614
  }
4627
4615
  /**
4628
- * Get the current list of available tools.
4629
- * Consolidates tools from all connected clients.
4616
+ * Get the current list of available effects.
4617
+ * Consolidates effect declarations and live availability for the session.
4630
4618
  */
4631
- getTools() {
4619
+ getEffects() {
4632
4620
  const doc = this.client.doc;
4633
4621
  const toolMap = /* @__PURE__ */ new Map();
4634
4622
  const domainPkg = doc.domain?.packages?.domain;
@@ -4673,10 +4661,32 @@ var Session = class {
4673
4661
  return Array.from(toolMap.values());
4674
4662
  }
4675
4663
  /**
4676
- * Subscribe to tool changes (added, removed, updated).
4664
+ * Backwards-compatible alias for `getEffects()`.
4665
+ */
4666
+ getTools() {
4667
+ return this.getEffects();
4668
+ }
4669
+ /**
4670
+ * Subscribe to effect changes (added, removed, updated).
4677
4671
  * @param callback - Function called with change events
4678
4672
  * @returns Unsubscribe function
4679
4673
  */
4674
+ onEffectsChanged(callback) {
4675
+ const handler = (data) => callback(data);
4676
+ if (!this.eventListeners.has("effects:changed")) {
4677
+ this.eventListeners.set("effects:changed", []);
4678
+ }
4679
+ this.eventListeners.get("effects:changed").push(handler);
4680
+ return () => {
4681
+ const listeners = this.eventListeners.get("effects:changed");
4682
+ if (listeners) {
4683
+ this.eventListeners.set("effects:changed", listeners.filter((h) => h !== handler));
4684
+ }
4685
+ };
4686
+ }
4687
+ /**
4688
+ * Backwards-compatible alias for `onEffectsChanged()`.
4689
+ */
4680
4690
  onToolsChanged(callback) {
4681
4691
  const handler = (data) => callback(data);
4682
4692
  if (!this.eventListeners.has("tools:changed")) {
@@ -4789,7 +4799,7 @@ import { ${allImports} } from "./sandbox-tools";
4789
4799
  }
4790
4800
  }
4791
4801
  if (globalTools && globalTools.length > 0) {
4792
- docs2 += "## Global Tools\n\n";
4802
+ docs2 += "## Global Effects\n\n";
4793
4803
  for (const tool of globalTools) {
4794
4804
  docs2 += `### ${tool.name}
4795
4805
 
@@ -4807,10 +4817,10 @@ import { ${allImports} } from "./sandbox-tools";
4807
4817
  return docs2;
4808
4818
  }
4809
4819
  if (!tools || tools.length === 0) {
4810
- return "No tools available in this domain.";
4820
+ return "No effects available in this domain.";
4811
4821
  }
4812
- let docs = "# Available Tools\n\n";
4813
- docs += "Import tools from `./sandbox-tools` and call them with await:\n\n";
4822
+ let docs = "# Available Effects\n\n";
4823
+ docs += "Import effects from `./sandbox-tools` and call them with await:\n\n";
4814
4824
  docs += '```typescript\nimport { tools } from "./sandbox-tools";\n\n';
4815
4825
  docs += "// Example:\n";
4816
4826
  docs += `const result = await tools.${tools[0]?.name || "example"}(input);
@@ -4842,6 +4852,13 @@ import { ${allImports} } from "./sandbox-tools";
4842
4852
  * Close the session and disconnect from the sandbox
4843
4853
  */
4844
4854
  async disconnect() {
4855
+ try {
4856
+ await this.client.call("client.goodbye", {
4857
+ clientId: this.clientId,
4858
+ timestamp: Date.now()
4859
+ });
4860
+ } catch (error) {
4861
+ }
4845
4862
  this.client.disconnect();
4846
4863
  }
4847
4864
  // --- Event Handling ---
@@ -4867,6 +4884,7 @@ import { ${allImports} } from "./sandbox-tools";
4867
4884
  setupToolInvokeHandler() {
4868
4885
  this.client.registerRpcHandler("tool.invoke", async (params) => {
4869
4886
  const { callId, toolName, input } = params;
4887
+ this.emit("effect:invoke", { callId, effectKey: toolName, toolName, input });
4870
4888
  this.emit("tool:invoke", { callId, toolName, input });
4871
4889
  const handler = this.toolHandlers.get(toolName);
4872
4890
  if (!handler) {
@@ -4878,12 +4896,14 @@ import { ${allImports} } from "./sandbox-tools";
4878
4896
  }
4879
4897
  try {
4880
4898
  let result;
4899
+ const invocationContext = this.buildLegacyEffectContext();
4881
4900
  if (this.instanceTools.has(toolName) && input && typeof input === "object" && "_objectId" in input) {
4882
4901
  const { _objectId, ...restParams } = input;
4883
- result = await handler(_objectId, restParams);
4902
+ result = await handler(_objectId, restParams, invocationContext);
4884
4903
  } else {
4885
- result = await handler(input);
4904
+ result = await handler(input, invocationContext);
4886
4905
  }
4906
+ this.emit("effect:result", { callId, effectKey: toolName, result });
4887
4907
  this.emit("tool:result", { callId, result });
4888
4908
  await this.client.call("tool.result", {
4889
4909
  callId,
@@ -4891,6 +4911,7 @@ import { ${allImports} } from "./sandbox-tools";
4891
4911
  });
4892
4912
  } catch (error) {
4893
4913
  const errorMessage = error instanceof Error ? error.message : String(error);
4914
+ this.emit("effect:result", { callId, effectKey: toolName, error: errorMessage });
4894
4915
  this.emit("tool:result", { callId, error: errorMessage });
4895
4916
  await this.client.call("tool.result", {
4896
4917
  callId,
@@ -4923,10 +4944,10 @@ import { ${allImports} } from "./sandbox-tools";
4923
4944
  }
4924
4945
  }
4925
4946
  /**
4926
- * Check for changes in the tool catalog and emit 'tools:changed' if needed
4947
+ * Check for changes in the effect catalog and emit change events if needed.
4927
4948
  */
4928
4949
  checkForToolChanges() {
4929
- const currentTools = this.getTools();
4950
+ const currentTools = this.getEffects();
4930
4951
  const currentMap = new Map(currentTools.map((t) => [t.name, t]));
4931
4952
  const added = [];
4932
4953
  const removed = [];
@@ -4942,6 +4963,12 @@ import { ${allImports} } from "./sandbox-tools";
4942
4963
  }
4943
4964
  if (added.length > 0 || removed.length > 0) {
4944
4965
  this.lastKnownTools = currentMap;
4966
+ this.emit("effects:changed", {
4967
+ effects: currentTools,
4968
+ tools: currentTools,
4969
+ added,
4970
+ removed
4971
+ });
4945
4972
  this.emit("tools:changed", {
4946
4973
  tools: currentTools,
4947
4974
  added,
@@ -5052,6 +5079,50 @@ var JobImplementation = class {
5052
5079
  }
5053
5080
  };
5054
5081
 
5082
+ // src/endpoints.ts
5083
+ var LOCAL_API_URL = "ws://localhost:8787/granular";
5084
+ var PRODUCTION_API_URL = "wss://api.granular.dev/v2/ws";
5085
+ function readEnv(name) {
5086
+ if (typeof process === "undefined" || !process.env) return void 0;
5087
+ return process.env[name];
5088
+ }
5089
+ function normalizeMode(value) {
5090
+ if (!value) return void 0;
5091
+ const normalized = value.trim().toLowerCase();
5092
+ if (normalized === "local") return "local";
5093
+ if (normalized === "prod" || normalized === "production") return "production";
5094
+ if (normalized === "auto") return "auto";
5095
+ return void 0;
5096
+ }
5097
+ function isTruthy(value) {
5098
+ if (!value) return false;
5099
+ const normalized = value.trim().toLowerCase();
5100
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
5101
+ }
5102
+ function resolveEndpointMode(explicitMode) {
5103
+ const explicit = normalizeMode(explicitMode);
5104
+ if (explicit === "local" || explicit === "production") {
5105
+ return explicit;
5106
+ }
5107
+ const envMode = normalizeMode(readEnv("GRANULAR_ENDPOINT_MODE") || readEnv("GRANULAR_ENV"));
5108
+ if (envMode === "local" || envMode === "production") {
5109
+ return envMode;
5110
+ }
5111
+ if (isTruthy(readEnv("GRANULAR_USE_LOCAL_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_LOCAL"))) {
5112
+ return "local";
5113
+ }
5114
+ if (isTruthy(readEnv("GRANULAR_USE_PRODUCTION_ENDPOINTS")) || isTruthy(readEnv("GRANULAR_PROD"))) {
5115
+ return "production";
5116
+ }
5117
+ return readEnv("NODE_ENV") === "development" ? "local" : "production";
5118
+ }
5119
+ function resolveApiUrl(explicitApiUrl, mode) {
5120
+ if (explicitApiUrl) {
5121
+ return explicitApiUrl;
5122
+ }
5123
+ return resolveEndpointMode(mode) === "local" ? LOCAL_API_URL : PRODUCTION_API_URL;
5124
+ }
5125
+
5055
5126
  // src/client.ts
5056
5127
  var STANDARD_MODULES_OPERATIONS = [
5057
5128
  { create: "entity", has: { id: { value: "auto-generated" }, createdAt: { value: void 0 } } },
@@ -5066,6 +5137,37 @@ var STANDARD_MODULES_OPERATIONS = [
5066
5137
  var BUILTIN_MODULES = {
5067
5138
  "standard_modules": STANDARD_MODULES_OPERATIONS
5068
5139
  };
5140
+ function computeEffectKey(effect) {
5141
+ const attachedClass = effect.className?.trim();
5142
+ if (!attachedClass) {
5143
+ return `global:${effect.name}`;
5144
+ }
5145
+ return effect.static ? `class:${attachedClass}:static:${effect.name}` : `class:${attachedClass}:instance:${effect.name}`;
5146
+ }
5147
+ function buildEffectHostUrl(apiUrl, sandboxId, effectClientId, clientId) {
5148
+ const url = new URL(apiUrl);
5149
+ if (url.pathname.endsWith("/granular/ws/connect")) {
5150
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5151
+ } else if (url.pathname.endsWith("/granular")) {
5152
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/effects/connect`;
5153
+ } else if (url.pathname.endsWith("/v2/ws/connect")) {
5154
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5155
+ } else if (url.pathname.endsWith("/v2/ws")) {
5156
+ url.pathname = url.pathname.replace(/\/ws$/, "/effects/connect");
5157
+ } else if (url.pathname.endsWith("/ws/connect")) {
5158
+ url.pathname = url.pathname.replace(/\/ws\/connect$/, "/effects/connect");
5159
+ } else if (url.pathname.endsWith("/ws")) {
5160
+ url.pathname = url.pathname.replace(/\/ws$/, "/effects/connect");
5161
+ } else {
5162
+ url.pathname = "/granular/effects/connect";
5163
+ }
5164
+ url.search = "";
5165
+ url.hash = "";
5166
+ url.searchParams.set("sandboxId", sandboxId);
5167
+ url.searchParams.set("effectClientId", effectClientId);
5168
+ url.searchParams.set("clientId", clientId);
5169
+ return url.toString();
5170
+ }
5069
5171
  var Environment = class _Environment extends Session {
5070
5172
  envData;
5071
5173
  _apiKey;
@@ -5096,6 +5198,60 @@ var Environment = class _Environment extends Session {
5096
5198
  get apiEndpoint() {
5097
5199
  return this._apiEndpoint;
5098
5200
  }
5201
+ getRuntimeBaseUrl() {
5202
+ try {
5203
+ const endpoint = new URL(this._apiEndpoint);
5204
+ const graphqlSuffix = "/orchestrator/graphql";
5205
+ if (endpoint.pathname.endsWith(graphqlSuffix)) {
5206
+ endpoint.pathname = endpoint.pathname.slice(0, -graphqlSuffix.length);
5207
+ } else if (endpoint.pathname.endsWith("/graphql")) {
5208
+ endpoint.pathname = endpoint.pathname.slice(0, -"/graphql".length);
5209
+ }
5210
+ endpoint.search = "";
5211
+ endpoint.hash = "";
5212
+ return endpoint.toString().replace(/\/$/, "");
5213
+ } catch {
5214
+ return this._apiEndpoint.replace(/\/orchestrator\/graphql$/, "").replace(/\/$/, "");
5215
+ }
5216
+ }
5217
+ /**
5218
+ * Close the session and disconnect from the sandbox.
5219
+ *
5220
+ * Sends `client.goodbye` over WebSocket first, then issues an HTTP fallback
5221
+ * to the runtime goodbye endpoint if no definitive WS-side runtime notify
5222
+ * acknowledgement was observed.
5223
+ */
5224
+ async disconnect() {
5225
+ let wsNotifiedRuntime = false;
5226
+ try {
5227
+ const goodbye = await this.rpc("client.goodbye", {
5228
+ timestamp: Date.now()
5229
+ });
5230
+ wsNotifiedRuntime = Boolean(goodbye?.ok && goodbye?.via);
5231
+ } catch {
5232
+ wsNotifiedRuntime = false;
5233
+ }
5234
+ if (!wsNotifiedRuntime) {
5235
+ try {
5236
+ const runtimeBase = this.getRuntimeBaseUrl();
5237
+ await fetch(
5238
+ `${runtimeBase}/orchestrator/runtime/environments/${this.environmentId}/session-goodbye`,
5239
+ {
5240
+ method: "POST",
5241
+ headers: {
5242
+ "Content-Type": "application/json",
5243
+ "Authorization": `Bearer ${this._apiKey}`
5244
+ },
5245
+ body: JSON.stringify({
5246
+ reason: "sdk_disconnect_http_fallback"
5247
+ })
5248
+ }
5249
+ );
5250
+ } catch {
5251
+ }
5252
+ }
5253
+ this.client.disconnect();
5254
+ }
5099
5255
  // ==================== GRAPH CONTAINER READINESS ====================
5100
5256
  /** The last known graph container status, updated by checkReadiness() or on heartbeat */
5101
5257
  graphContainerStatus = null;
@@ -5756,72 +5912,31 @@ var Environment = class _Environment extends Session {
5756
5912
  }
5757
5913
  // ==================== PUBLISH TOOLS ====================
5758
5914
  /**
5759
- * Convenience method: publish tools and get back wrapped result.
5760
- * This is the main entry point for setting up tools.
5761
- *
5762
- * @example
5763
- * ```typescript
5764
- * const tools = [
5765
- * {
5766
- * name: 'get_weather',
5767
- * description: 'Get weather for a city',
5768
- * inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
5769
- * handler: async ({ city }) => ({ temp: 22 }),
5770
- * },
5771
- * ];
5772
- *
5773
- * const { domainRevision } = await environment.publishTools(tools);
5774
- *
5775
- * // Now submit jobs that use those tools
5776
- * const job = await environment.submitJob(`
5777
- * import { Author } from './sandbox-tools';
5778
- * const weather = await Author.get_weather({ city: 'Paris' });
5779
- * return weather;
5780
- * `);
5781
- *
5782
- * const result = await job.result;
5783
- * ```
5915
+ * Removed: environment-scoped effect publication is no longer supported.
5784
5916
  */
5785
5917
  async publishTools(tools, revision = "1.0.0") {
5786
5918
  return super.publishTools(tools, revision);
5787
5919
  }
5788
5920
  /**
5789
- * Publish a single effect (tool) incrementally.
5790
- *
5791
- * Adds the effect to the local registry and re-publishes the
5792
- * full tool catalog to the server. If an effect with the same
5793
- * name already exists, it is replaced.
5794
- *
5795
- * @example
5796
- * ```typescript
5797
- * await env.publishEffect({
5798
- * name: 'get_bio',
5799
- * description: 'Get biography',
5800
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
5801
- * handler: async (params) => ({ bio: 'Hello' }),
5802
- * });
5803
- * ```
5921
+ * Removed: environment-scoped effect publication is no longer supported.
5804
5922
  */
5805
5923
  async publishEffect(effect) {
5806
5924
  return super.publishEffect(effect);
5807
5925
  }
5808
5926
  /**
5809
- * Publish multiple effects (tools) at once.
5810
- *
5811
- * Adds all effects to the local registry and re-publishes the
5812
- * full tool catalog in a single RPC call.
5927
+ * Removed: environment-scoped effect publication is no longer supported.
5813
5928
  */
5814
5929
  async publishEffects(effects) {
5815
5930
  return super.publishEffects(effects);
5816
5931
  }
5817
5932
  /**
5818
- * Remove an effect by name and re-publish the remaining catalog.
5933
+ * Removed: environment-scoped effect publication is no longer supported.
5819
5934
  */
5820
5935
  async unpublishEffect(name) {
5821
5936
  return super.unpublishEffect(name);
5822
5937
  }
5823
5938
  /**
5824
- * Remove all effects and publish an empty catalog.
5939
+ * Removed: environment-scoped effect publication is no longer supported.
5825
5940
  */
5826
5941
  async unpublishAllEffects() {
5827
5942
  return super.unpublishAllEffects();
@@ -5831,13 +5946,16 @@ var Granular = class {
5831
5946
  apiKey;
5832
5947
  apiUrl;
5833
5948
  httpUrl;
5949
+ tokenProvider;
5834
5950
  WebSocketCtor;
5835
5951
  onUnexpectedClose;
5836
5952
  onReconnectError;
5837
- /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
5953
+ /** Sandbox-level effect registry: sandboxId → (effectKey → ToolWithHandler) */
5838
5954
  sandboxEffects = /* @__PURE__ */ new Map();
5839
- /** Active environments tracker: sandboxId Environment[] */
5840
- activeEnvironments = /* @__PURE__ */ new Map();
5955
+ /** Live sandbox-scoped effect hosts keyed by sandboxId */
5956
+ sandboxEffectHosts = /* @__PURE__ */ new Map();
5957
+ /** In-flight host connection promises to avoid duplicate concurrent connects */
5958
+ sandboxEffectHostPromises = /* @__PURE__ */ new Map();
5841
5959
  /**
5842
5960
  * Create a new Granular client
5843
5961
  * @param options - Client configuration
@@ -5848,11 +5966,12 @@ var Granular = class {
5848
5966
  throw new Error("Granular client requires either apiKey or token. Set GRANULAR_API_KEY or GRANULAR_TOKEN, or pass one in options.");
5849
5967
  }
5850
5968
  this.apiKey = auth;
5851
- this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
5969
+ this.apiUrl = resolveApiUrl(options.apiUrl, options.endpointMode);
5970
+ this.tokenProvider = options.tokenProvider;
5852
5971
  this.WebSocketCtor = options.WebSocketCtor;
5853
5972
  this.onUnexpectedClose = options.onUnexpectedClose;
5854
5973
  this.onReconnectError = options.onReconnectError;
5855
- this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
5974
+ this.httpUrl = this.apiUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://").replace(/\/ws$/, "");
5856
5975
  }
5857
5976
  /**
5858
5977
  * Records/upserts a user and prepares them for sandbox connections
@@ -5889,8 +6008,9 @@ var Granular = class {
5889
6008
  /**
5890
6009
  * Connect to a sandbox and establish a real-time environment session.
5891
6010
  *
5892
- * After connecting, use `environment.publishTools()` to register tools,
5893
- * then `environment.submitJob()` to execute code that uses those tools.
6011
+ * Effects are registered at the sandbox level via `granular.registerEffect()`
6012
+ * or `granular.registerEffects()`. Sessions pick up live availability from
6013
+ * the sandbox registry automatically.
5894
6014
  *
5895
6015
  * @param options - Connection options
5896
6016
  * @returns An active environment session
@@ -5907,10 +6027,12 @@ var Granular = class {
5907
6027
  * user,
5908
6028
  * });
5909
6029
  *
5910
- * // Publish tools
5911
- * await environment.publishTools([
5912
- * { name: 'greet', description: 'Say hello', inputSchema: {}, handler: async () => 'Hello!' },
5913
- * ]);
6030
+ * await granular.registerEffect('my-sandbox', {
6031
+ * name: 'greet',
6032
+ * description: 'Say hello',
6033
+ * inputSchema: { type: 'object', properties: {} },
6034
+ * handler: async () => 'Hello!',
6035
+ * });
5914
6036
  *
5915
6037
  * // Submit job
5916
6038
  * const job = await environment.submitJob(`
@@ -5932,10 +6054,19 @@ var Granular = class {
5932
6054
  subjectId: options.user.subjectId,
5933
6055
  permissionProfileId: null
5934
6056
  });
6057
+ await this.activateEnvironment(envData.environmentId);
6058
+ const session = await this.request("/ws/sessions", {
6059
+ method: "POST",
6060
+ body: JSON.stringify({
6061
+ environmentId: envData.environmentId,
6062
+ clientId
6063
+ })
6064
+ });
5935
6065
  const client = new WSClient({
5936
- url: this.apiUrl,
5937
- sessionId: envData.environmentId,
5938
- token: this.apiKey,
6066
+ url: session.wsUrl,
6067
+ sessionId: session.sessionId,
6068
+ token: session.token,
6069
+ tokenProvider: this.tokenProvider,
5939
6070
  WebSocketCtor: this.WebSocketCtor,
5940
6071
  onUnexpectedClose: this.onUnexpectedClose,
5941
6072
  onReconnectError: this.onReconnectError
@@ -5943,47 +6074,165 @@ var Granular = class {
5943
6074
  await client.connect();
5944
6075
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;
5945
6076
  const environment = new Environment(client, envData, clientId, this.apiKey, graphqlEndpoint);
5946
- if (!this.activeEnvironments.has(sandbox.sandboxId)) {
5947
- this.activeEnvironments.set(sandbox.sandboxId, []);
5948
- }
5949
- this.activeEnvironments.get(sandbox.sandboxId).push(environment);
5950
- environment.on("disconnect", () => {
5951
- const list = this.activeEnvironments.get(sandbox.sandboxId);
5952
- if (list) {
5953
- this.activeEnvironments.set(sandbox.sandboxId, list.filter((e) => e !== environment));
5954
- }
5955
- });
5956
6077
  await environment.hello();
5957
- const effects = this.sandboxEffects.get(sandbox.sandboxId);
5958
- if (effects && effects.size > 0) {
5959
- const effectsList = Array.from(effects.values());
5960
- console.log(`[Granular] Auto-publishing ${effectsList.length} effects for sandbox ${sandbox.sandboxId}`);
5961
- await environment.publishEffects(effectsList);
5962
- }
5963
6078
  return environment;
5964
6079
  }
6080
+ async activateEnvironment(environmentId) {
6081
+ await this.request(`/orchestrator/runtime/environments/${environmentId}/activate`, {
6082
+ method: "POST",
6083
+ body: JSON.stringify({})
6084
+ });
6085
+ }
5965
6086
  // ── Sandbox-Level Effects ──
6087
+ getSandboxEffectMap(sandboxId) {
6088
+ let effects = this.sandboxEffects.get(sandboxId);
6089
+ if (!effects) {
6090
+ effects = /* @__PURE__ */ new Map();
6091
+ this.sandboxEffects.set(sandboxId, effects);
6092
+ }
6093
+ return effects;
6094
+ }
6095
+ serializeEffect(effect) {
6096
+ return {
6097
+ effectKey: computeEffectKey(effect),
6098
+ name: effect.name,
6099
+ description: effect.description,
6100
+ inputSchema: effect.inputSchema,
6101
+ outputSchema: effect.outputSchema,
6102
+ stability: effect.stability || "stable",
6103
+ provenance: effect.provenance || { source: "custom" },
6104
+ tags: effect.tags,
6105
+ className: effect.className,
6106
+ static: effect.static
6107
+ };
6108
+ }
6109
+ async publishSandboxEffectCatalog(host) {
6110
+ const effects = Array.from(this.getSandboxEffectMap(host.sandboxId).values()).map(
6111
+ (effect) => this.serializeEffect(effect)
6112
+ );
6113
+ await host.wsClient.call("effects.publishCatalog", { effects });
6114
+ }
6115
+ async syncSandboxEffectCatalog(sandboxId) {
6116
+ const host = await this.ensureSandboxEffectHost(sandboxId);
6117
+ await this.publishSandboxEffectCatalog(host);
6118
+ }
6119
+ startEffectHostHeartbeat(host) {
6120
+ if (host.heartbeatTimer) {
6121
+ clearInterval(host.heartbeatTimer);
6122
+ }
6123
+ host.wsClient.call("client.heartbeat", {}).catch((error) => {
6124
+ console.warn(
6125
+ `[Granular] Initial effect host heartbeat failed for sandbox ${host.sandboxId}:`,
6126
+ error
6127
+ );
6128
+ });
6129
+ host.heartbeatTimer = setInterval(() => {
6130
+ host.wsClient.call("client.heartbeat", {}).catch((error) => {
6131
+ console.warn(
6132
+ `[Granular] Effect host heartbeat failed for sandbox ${host.sandboxId}:`,
6133
+ error
6134
+ );
6135
+ });
6136
+ }, 1e4);
6137
+ }
6138
+ stopEffectHostHeartbeat(host) {
6139
+ if (!host?.heartbeatTimer) {
6140
+ return;
6141
+ }
6142
+ clearInterval(host.heartbeatTimer);
6143
+ host.heartbeatTimer = null;
6144
+ }
6145
+ async synchronizeEffectHost(host) {
6146
+ await host.wsClient.call("client.hello", {
6147
+ clientId: host.clientId,
6148
+ protocolVersion: "2.0"
6149
+ });
6150
+ this.startEffectHostHeartbeat(host);
6151
+ await this.publishSandboxEffectCatalog(host);
6152
+ }
6153
+ async ensureSandboxEffectHost(sandboxId) {
6154
+ const existing = this.sandboxEffectHosts.get(sandboxId);
6155
+ if (existing) {
6156
+ return existing;
6157
+ }
6158
+ const inflight = this.sandboxEffectHostPromises.get(sandboxId);
6159
+ if (inflight) {
6160
+ return inflight;
6161
+ }
6162
+ const connectPromise = (async () => {
6163
+ const effectClientId = crypto.randomUUID();
6164
+ const clientId = `effect-host:${sandboxId}:${effectClientId}`;
6165
+ const wsClient = new WSClient({
6166
+ url: buildEffectHostUrl(this.apiUrl, sandboxId, effectClientId, clientId),
6167
+ sessionId: `effect-host:${effectClientId}`,
6168
+ token: this.apiKey,
6169
+ tokenProvider: this.tokenProvider,
6170
+ WebSocketCtor: this.WebSocketCtor,
6171
+ onUnexpectedClose: this.onUnexpectedClose,
6172
+ onReconnectError: this.onReconnectError
6173
+ });
6174
+ const host = {
6175
+ sandboxId,
6176
+ effectClientId,
6177
+ clientId,
6178
+ wsClient,
6179
+ heartbeatTimer: null
6180
+ };
6181
+ wsClient.registerRpcHandler("effect.invoke", async (params) => {
6182
+ const request = params;
6183
+ const effect = this.getSandboxEffectMap(sandboxId).get(request.effectKey);
6184
+ if (!effect) {
6185
+ throw new Error(`Effect handler not found: ${request.effectKey}`);
6186
+ }
6187
+ if (effect.className && !effect.static && request.input && typeof request.input === "object" && "_objectId" in request.input) {
6188
+ const { _objectId, ...rest } = request.input;
6189
+ return effect.handler(_objectId, rest, request.context);
6190
+ }
6191
+ return effect.handler(request.input, request.context);
6192
+ });
6193
+ wsClient.on("open", () => {
6194
+ void this.synchronizeEffectHost(host).catch((error) => {
6195
+ console.error(
6196
+ `[Granular] Failed to re-sync effect host for sandbox ${sandboxId}:`,
6197
+ error
6198
+ );
6199
+ });
6200
+ });
6201
+ wsClient.on("disconnect", () => {
6202
+ this.stopEffectHostHeartbeat(host);
6203
+ });
6204
+ await wsClient.connect();
6205
+ await this.synchronizeEffectHost(host);
6206
+ this.sandboxEffectHosts.set(sandboxId, host);
6207
+ return host;
6208
+ })();
6209
+ this.sandboxEffectHostPromises.set(sandboxId, connectPromise);
6210
+ try {
6211
+ return await connectPromise;
6212
+ } finally {
6213
+ this.sandboxEffectHostPromises.delete(sandboxId);
6214
+ }
6215
+ }
6216
+ disconnectSandboxEffectHost(sandboxId) {
6217
+ const host = this.sandboxEffectHosts.get(sandboxId);
6218
+ if (!host) {
6219
+ return;
6220
+ }
6221
+ this.stopEffectHostHeartbeat(host);
6222
+ host.wsClient.disconnect();
6223
+ this.sandboxEffectHosts.delete(sandboxId);
6224
+ }
5966
6225
  /**
5967
6226
  * Register an effect (tool) for a specific sandbox.
5968
- *
5969
- * The effect will be automatically published to:
5970
- * 1. Any currently active environments for this sandbox
5971
- * 2. Any new environments created/connected for this sandbox
5972
- *
6227
+ *
5973
6228
  * @param sandboxNameOrId - The name or ID of the sandbox
5974
6229
  * @param effect - The tool definition and handler
5975
6230
  */
5976
6231
  async registerEffect(sandboxNameOrId, effect) {
5977
6232
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
5978
6233
  const sandboxId = sandbox.sandboxId;
5979
- if (!this.sandboxEffects.has(sandboxId)) {
5980
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
5981
- }
5982
- this.sandboxEffects.get(sandboxId).set(effect.name, effect);
5983
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
5984
- for (const env of activeEnvs) {
5985
- await env.publishEffect(effect);
5986
- }
6234
+ this.getSandboxEffectMap(sandboxId).set(computeEffectKey(effect), effect);
6235
+ await this.syncSandboxEffectCatalog(sandboxId);
5987
6236
  }
5988
6237
  /**
5989
6238
  * Register multiple effects (tools) for a specific sandbox.
@@ -5993,35 +6242,39 @@ var Granular = class {
5993
6242
  async registerEffects(sandboxNameOrId, effects) {
5994
6243
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
5995
6244
  const sandboxId = sandbox.sandboxId;
5996
- if (!this.sandboxEffects.has(sandboxId)) {
5997
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
5998
- }
5999
- const map = this.sandboxEffects.get(sandboxId);
6245
+ const map = this.getSandboxEffectMap(sandboxId);
6000
6246
  for (const effect of effects) {
6001
- map.set(effect.name, effect);
6002
- }
6003
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6004
- for (const env of activeEnvs) {
6005
- await env.publishEffects(effects);
6247
+ map.set(computeEffectKey(effect), effect);
6006
6248
  }
6249
+ await this.syncSandboxEffectCatalog(sandboxId);
6007
6250
  }
6008
6251
  /**
6009
6252
  * Unregister an effect from a sandbox.
6010
6253
  *
6011
- * Removes it from the local registry and unpublishes it from
6012
- * all active environments.
6254
+ * Removes it from the local sandbox registry and updates the
6255
+ * sandbox-scoped live catalog.
6013
6256
  */
6014
6257
  async unregisterEffect(sandboxNameOrId, name) {
6015
6258
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6016
6259
  const sandboxId = sandbox.sandboxId;
6017
- const map = this.sandboxEffects.get(sandboxId);
6018
- if (map) {
6019
- map.delete(name);
6260
+ const currentMap = this.sandboxEffects.get(sandboxId);
6261
+ if (!currentMap) {
6262
+ return;
6020
6263
  }
6021
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6022
- for (const env of activeEnvs) {
6023
- await env.unpublishEffect(name);
6264
+ const nextEntries = Array.from(currentMap.entries()).filter(
6265
+ ([effectKey, effect]) => effectKey !== name && effect.name !== name
6266
+ );
6267
+ if (nextEntries.length === currentMap.size) {
6268
+ return;
6024
6269
  }
6270
+ const nextMap = new Map(nextEntries);
6271
+ this.sandboxEffects.set(sandboxId, nextMap);
6272
+ if (nextMap.size === 0) {
6273
+ this.disconnectSandboxEffectHost(sandboxId);
6274
+ this.sandboxEffects.delete(sandboxId);
6275
+ return;
6276
+ }
6277
+ await this.syncSandboxEffectCatalog(sandboxId);
6025
6278
  }
6026
6279
  /**
6027
6280
  * Unregister all effects for a sandbox.
@@ -6030,10 +6283,7 @@ var Granular = class {
6030
6283
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6031
6284
  const sandboxId = sandbox.sandboxId;
6032
6285
  this.sandboxEffects.delete(sandboxId);
6033
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6034
- for (const env of activeEnvs) {
6035
- await env.unpublishAllEffects();
6036
- }
6286
+ this.disconnectSandboxEffectHost(sandboxId);
6037
6287
  }
6038
6288
  /**
6039
6289
  * Find a sandbox by name or create it if it doesn't exist
@@ -6069,7 +6319,7 @@ var Granular = class {
6069
6319
  const created = await this.permissionProfiles.create(sandboxId, {
6070
6320
  name: profileName,
6071
6321
  rules: {
6072
- tools: { allow: ["*"] },
6322
+ effects: { allow: ["*"] },
6073
6323
  resources: { allow: ["*"] }
6074
6324
  }
6075
6325
  });