@granular-software/sdk 0.4.1 → 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
@@ -4466,6 +4466,17 @@ var Session = class {
4466
4466
  this.setupEventHandlers();
4467
4467
  this.setupToolInvokeHandler();
4468
4468
  }
4469
+ buildLegacyEffectContext() {
4470
+ return {
4471
+ effectClientId: this.clientId,
4472
+ sandboxId: "",
4473
+ environmentId: "",
4474
+ sessionId: "",
4475
+ user: {
4476
+ subjectId: ""
4477
+ }
4478
+ };
4479
+ }
4469
4480
  // --- Public API ---
4470
4481
  get document() {
4471
4482
  return this.client.doc;
@@ -4504,155 +4515,30 @@ var Session = class {
4504
4515
  });
4505
4516
  return result;
4506
4517
  }
4507
- /**
4508
- * Publish tools to the sandbox and register handlers for reverse-RPC.
4509
- *
4510
- * Tools can be:
4511
- * - **Instance methods**: set `className` (handler receives `(objectId, params)`)
4512
- * - **Static methods**: set `className` + `static: true` (handler receives `(params)`)
4513
- * - **Global tools**: omit `className` (handler receives `(params)`)
4514
- *
4515
- * Both `inputSchema` and `outputSchema` accept JSON Schema objects. The
4516
- * `outputSchema` drives the return type in the auto-generated TypeScript
4517
- * class declarations that sandbox code imports from `./sandbox-tools`.
4518
- *
4519
- * This method:
4520
- * 1. Extracts tool schemas (including `outputSchema`) from the provided tools
4521
- * 2. Publishes them via `client.publishRawToolCatalog` RPC
4522
- * 3. Registers handlers locally for `tool.invoke` RPC calls
4523
- * 4. Returns the `domainRevision` needed for job submission
4524
- *
4525
- * @param tools - Array of tools with handlers
4526
- * @param revision - Optional revision string (default: "1.0.0")
4527
- * @returns PublishToolsResult with domainRevision
4528
- *
4529
- * @example
4530
- * ```typescript
4531
- * await env.publishTools([
4532
- * {
4533
- * name: 'get_bio',
4534
- * description: 'Get biography of an author',
4535
- * className: 'author',
4536
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4537
- * outputSchema: { type: 'object', properties: { bio: { type: 'string' } }, required: ['bio'] },
4538
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4539
- * },
4540
- * ]);
4541
- * ```
4542
- */
4543
4518
  async publishTools(tools, revision = "1.0.0") {
4544
- const schemas = tools.map((tool) => ({
4545
- name: tool.name,
4546
- description: tool.description,
4547
- inputSchema: tool.inputSchema,
4548
- outputSchema: tool.outputSchema,
4549
- stability: tool.stability || "stable",
4550
- provenance: tool.provenance || { source: "mcp" },
4551
- tags: tool.tags,
4552
- className: tool.className,
4553
- static: tool.static
4554
- }));
4555
- const result = await this.client.call("client.publishRawToolCatalog", {
4556
- clientId: this.clientId,
4557
- revision,
4558
- tools: schemas
4559
- });
4560
- if (!result.accepted || !result.domainRevision) {
4561
- throw new Error(`Failed to publish tools: ${JSON.stringify(result.rejected)}`);
4562
- }
4563
- for (const tool of tools) {
4564
- this.toolHandlers.set(tool.name, tool.handler);
4565
- if (tool.className && !tool.static) {
4566
- this.instanceTools.add(tool.name);
4567
- } else {
4568
- this.instanceTools.delete(tool.name);
4569
- }
4570
- }
4571
- this.currentDomainRevision = result.domainRevision;
4572
- return {
4573
- accepted: result.accepted,
4574
- domainRevision: result.domainRevision,
4575
- rejected: result.rejected
4576
- };
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
+ );
4577
4522
  }
4578
- /**
4579
- * Publish a single effect (tool) to the sandbox.
4580
- *
4581
- * Adds the effect to the local registry and re-publishes the
4582
- * full tool catalog to the server. If an effect with the same
4583
- * name already exists, it is replaced.
4584
- *
4585
- * @param effect - The effect (tool) to publish
4586
- * @returns PublishToolsResult with domainRevision
4587
- *
4588
- * @example
4589
- * ```typescript
4590
- * await env.publishEffect({
4591
- * name: 'get_bio',
4592
- * description: 'Get biography of an author',
4593
- * className: 'author',
4594
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
4595
- * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
4596
- * });
4597
- * ```
4598
- */
4599
4523
  async publishEffect(effect) {
4600
- this.effects.set(effect.name, effect);
4601
- return this._syncEffects();
4524
+ throw new Error(
4525
+ "Environment-scoped effect publication was removed. Use granular.registerEffect(environment.sandboxId, effect)."
4526
+ );
4602
4527
  }
4603
- /**
4604
- * Publish multiple effects (tools) at once.
4605
- *
4606
- * Adds all effects to the local registry and re-publishes the
4607
- * full tool catalog in a single RPC call.
4608
- *
4609
- * @param effects - Array of effects to publish
4610
- * @returns PublishToolsResult with domainRevision
4611
- *
4612
- * @example
4613
- * ```typescript
4614
- * await env.publishEffects([
4615
- * { name: 'get_bio', description: '...', inputSchema: {}, handler: async (id) => ({}) },
4616
- * { name: 'search', description: '...', inputSchema: {}, handler: async (params) => ({}) },
4617
- * ]);
4618
- * ```
4619
- */
4620
4528
  async publishEffects(effects) {
4621
- for (const effect of effects) {
4622
- this.effects.set(effect.name, effect);
4623
- }
4624
- return this._syncEffects();
4529
+ throw new Error(
4530
+ "Environment-scoped effect publication was removed. Use granular.registerEffects(environment.sandboxId, effects)."
4531
+ );
4625
4532
  }
4626
- /**
4627
- * Remove an effect by name and re-publish the remaining catalog.
4628
- *
4629
- * @param name - The name of the effect to remove
4630
- * @returns PublishToolsResult with domainRevision
4631
- */
4632
4533
  async unpublishEffect(name) {
4633
- this.effects.delete(name);
4634
- this.toolHandlers.delete(name);
4635
- this.instanceTools.delete(name);
4636
- return this._syncEffects();
4534
+ throw new Error(
4535
+ "Environment-scoped effect publication was removed. Use granular.unregisterEffect(environment.sandboxId, effectName)."
4536
+ );
4637
4537
  }
4638
- /**
4639
- * Remove all effects and publish an empty catalog.
4640
- *
4641
- * @returns PublishToolsResult with domainRevision
4642
- */
4643
4538
  async unpublishAllEffects() {
4644
- this.effects.clear();
4645
- this.toolHandlers.clear();
4646
- this.instanceTools.clear();
4647
- return this._syncEffects();
4648
- }
4649
- /**
4650
- * Internal: re-publish the full effect catalog to the server.
4651
- * Called after any mutation to the effects Map.
4652
- */
4653
- async _syncEffects() {
4654
- const allEffects = Array.from(this.effects.values());
4655
- return this.publishTools(allEffects);
4539
+ throw new Error(
4540
+ "Environment-scoped effect publication was removed. Use granular.unregisterAllEffects(environment.sandboxId)."
4541
+ );
4656
4542
  }
4657
4543
  /**
4658
4544
  * Submit a job to execute code in the sandbox.
@@ -4666,14 +4552,14 @@ var Session = class {
4666
4552
  * const books = await tolkien.get_books();
4667
4553
  * ```
4668
4554
  *
4669
- * Tool calls (instance methods, static methods, global functions) trigger
4670
- * `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
4671
4557
  * execute locally and return the result to the sandbox.
4672
4558
  */
4673
4559
  async submitJob(code, domainRevision) {
4674
4560
  const revision = domainRevision || this.currentDomainRevision || this.client.doc?.domain?.active || void 0;
4675
4561
  if (!revision) {
4676
- 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.");
4677
4563
  }
4678
4564
  const result = await this.client.call("job.submit", {
4679
4565
  domainRevision: revision,
@@ -4705,10 +4591,10 @@ var Session = class {
4705
4591
  await this.client.call("prompt.answer", { promptId, value: answer });
4706
4592
  }
4707
4593
  /**
4708
- * Get the current list of available tools.
4709
- * Consolidates tools from all connected clients.
4594
+ * Get the current list of available effects.
4595
+ * Consolidates effect declarations and live availability for the session.
4710
4596
  */
4711
- getTools() {
4597
+ getEffects() {
4712
4598
  const doc = this.client.doc;
4713
4599
  const toolMap = /* @__PURE__ */ new Map();
4714
4600
  const domainPkg = doc.domain?.packages?.domain;
@@ -4753,10 +4639,32 @@ var Session = class {
4753
4639
  return Array.from(toolMap.values());
4754
4640
  }
4755
4641
  /**
4756
- * 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).
4757
4649
  * @param callback - Function called with change events
4758
4650
  * @returns Unsubscribe function
4759
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
+ */
4760
4668
  onToolsChanged(callback) {
4761
4669
  const handler = (data) => callback(data);
4762
4670
  if (!this.eventListeners.has("tools:changed")) {
@@ -4869,7 +4777,7 @@ import { ${allImports} } from "./sandbox-tools";
4869
4777
  }
4870
4778
  }
4871
4779
  if (globalTools && globalTools.length > 0) {
4872
- docs2 += "## Global Tools\n\n";
4780
+ docs2 += "## Global Effects\n\n";
4873
4781
  for (const tool of globalTools) {
4874
4782
  docs2 += `### ${tool.name}
4875
4783
 
@@ -4887,10 +4795,10 @@ import { ${allImports} } from "./sandbox-tools";
4887
4795
  return docs2;
4888
4796
  }
4889
4797
  if (!tools || tools.length === 0) {
4890
- return "No tools available in this domain.";
4798
+ return "No effects available in this domain.";
4891
4799
  }
4892
- let docs = "# Available Tools\n\n";
4893
- 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";
4894
4802
  docs += '```typescript\nimport { tools } from "./sandbox-tools";\n\n';
4895
4803
  docs += "// Example:\n";
4896
4804
  docs += `const result = await tools.${tools[0]?.name || "example"}(input);
@@ -4954,6 +4862,7 @@ import { ${allImports} } from "./sandbox-tools";
4954
4862
  setupToolInvokeHandler() {
4955
4863
  this.client.registerRpcHandler("tool.invoke", async (params) => {
4956
4864
  const { callId, toolName, input } = params;
4865
+ this.emit("effect:invoke", { callId, effectKey: toolName, toolName, input });
4957
4866
  this.emit("tool:invoke", { callId, toolName, input });
4958
4867
  const handler = this.toolHandlers.get(toolName);
4959
4868
  if (!handler) {
@@ -4965,12 +4874,14 @@ import { ${allImports} } from "./sandbox-tools";
4965
4874
  }
4966
4875
  try {
4967
4876
  let result;
4877
+ const invocationContext = this.buildLegacyEffectContext();
4968
4878
  if (this.instanceTools.has(toolName) && input && typeof input === "object" && "_objectId" in input) {
4969
4879
  const { _objectId, ...restParams } = input;
4970
- result = await handler(_objectId, restParams);
4880
+ result = await handler(_objectId, restParams, invocationContext);
4971
4881
  } else {
4972
- result = await handler(input);
4882
+ result = await handler(input, invocationContext);
4973
4883
  }
4884
+ this.emit("effect:result", { callId, effectKey: toolName, result });
4974
4885
  this.emit("tool:result", { callId, result });
4975
4886
  await this.client.call("tool.result", {
4976
4887
  callId,
@@ -4978,6 +4889,7 @@ import { ${allImports} } from "./sandbox-tools";
4978
4889
  });
4979
4890
  } catch (error) {
4980
4891
  const errorMessage = error instanceof Error ? error.message : String(error);
4892
+ this.emit("effect:result", { callId, effectKey: toolName, error: errorMessage });
4981
4893
  this.emit("tool:result", { callId, error: errorMessage });
4982
4894
  await this.client.call("tool.result", {
4983
4895
  callId,
@@ -5010,10 +4922,10 @@ import { ${allImports} } from "./sandbox-tools";
5010
4922
  }
5011
4923
  }
5012
4924
  /**
5013
- * 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.
5014
4926
  */
5015
4927
  checkForToolChanges() {
5016
- const currentTools = this.getTools();
4928
+ const currentTools = this.getEffects();
5017
4929
  const currentMap = new Map(currentTools.map((t) => [t.name, t]));
5018
4930
  const added = [];
5019
4931
  const removed = [];
@@ -5029,6 +4941,12 @@ import { ${allImports} } from "./sandbox-tools";
5029
4941
  }
5030
4942
  if (added.length > 0 || removed.length > 0) {
5031
4943
  this.lastKnownTools = currentMap;
4944
+ this.emit("effects:changed", {
4945
+ effects: currentTools,
4946
+ tools: currentTools,
4947
+ added,
4948
+ removed
4949
+ });
5032
4950
  this.emit("tools:changed", {
5033
4951
  tools: currentTools,
5034
4952
  added,
@@ -5197,6 +5115,37 @@ var STANDARD_MODULES_OPERATIONS = [
5197
5115
  var BUILTIN_MODULES = {
5198
5116
  "standard_modules": STANDARD_MODULES_OPERATIONS
5199
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
+ }
5200
5149
  var Environment = class _Environment extends Session {
5201
5150
  envData;
5202
5151
  _apiKey;
@@ -5941,72 +5890,31 @@ var Environment = class _Environment extends Session {
5941
5890
  }
5942
5891
  // ==================== PUBLISH TOOLS ====================
5943
5892
  /**
5944
- * Convenience method: publish tools and get back wrapped result.
5945
- * This is the main entry point for setting up tools.
5946
- *
5947
- * @example
5948
- * ```typescript
5949
- * const tools = [
5950
- * {
5951
- * name: 'get_weather',
5952
- * description: 'Get weather for a city',
5953
- * inputSchema: { type: 'object', properties: { city: { type: 'string' } } },
5954
- * handler: async ({ city }) => ({ temp: 22 }),
5955
- * },
5956
- * ];
5957
- *
5958
- * const { domainRevision } = await environment.publishTools(tools);
5959
- *
5960
- * // Now submit jobs that use those tools
5961
- * const job = await environment.submitJob(`
5962
- * import { Author } from './sandbox-tools';
5963
- * const weather = await Author.get_weather({ city: 'Paris' });
5964
- * return weather;
5965
- * `);
5966
- *
5967
- * const result = await job.result;
5968
- * ```
5893
+ * Removed: environment-scoped effect publication is no longer supported.
5969
5894
  */
5970
5895
  async publishTools(tools, revision = "1.0.0") {
5971
5896
  return super.publishTools(tools, revision);
5972
5897
  }
5973
5898
  /**
5974
- * Publish a single effect (tool) incrementally.
5975
- *
5976
- * Adds the effect to the local registry and re-publishes the
5977
- * full tool catalog to the server. If an effect with the same
5978
- * name already exists, it is replaced.
5979
- *
5980
- * @example
5981
- * ```typescript
5982
- * await env.publishEffect({
5983
- * name: 'get_bio',
5984
- * description: 'Get biography',
5985
- * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
5986
- * handler: async (params) => ({ bio: 'Hello' }),
5987
- * });
5988
- * ```
5899
+ * Removed: environment-scoped effect publication is no longer supported.
5989
5900
  */
5990
5901
  async publishEffect(effect) {
5991
5902
  return super.publishEffect(effect);
5992
5903
  }
5993
5904
  /**
5994
- * Publish multiple effects (tools) at once.
5995
- *
5996
- * Adds all effects to the local registry and re-publishes the
5997
- * full tool catalog in a single RPC call.
5905
+ * Removed: environment-scoped effect publication is no longer supported.
5998
5906
  */
5999
5907
  async publishEffects(effects) {
6000
5908
  return super.publishEffects(effects);
6001
5909
  }
6002
5910
  /**
6003
- * Remove an effect by name and re-publish the remaining catalog.
5911
+ * Removed: environment-scoped effect publication is no longer supported.
6004
5912
  */
6005
5913
  async unpublishEffect(name) {
6006
5914
  return super.unpublishEffect(name);
6007
5915
  }
6008
5916
  /**
6009
- * Remove all effects and publish an empty catalog.
5917
+ * Removed: environment-scoped effect publication is no longer supported.
6010
5918
  */
6011
5919
  async unpublishAllEffects() {
6012
5920
  return super.unpublishAllEffects();
@@ -6020,10 +5928,12 @@ var Granular = class {
6020
5928
  WebSocketCtor;
6021
5929
  onUnexpectedClose;
6022
5930
  onReconnectError;
6023
- /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
5931
+ /** Sandbox-level effect registry: sandboxId → (effectKey → ToolWithHandler) */
6024
5932
  sandboxEffects = /* @__PURE__ */ new Map();
6025
- /** Active environments tracker: sandboxId Environment[] */
6026
- 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();
6027
5937
  /**
6028
5938
  * Create a new Granular client
6029
5939
  * @param options - Client configuration
@@ -6076,8 +5986,9 @@ var Granular = class {
6076
5986
  /**
6077
5987
  * Connect to a sandbox and establish a real-time environment session.
6078
5988
  *
6079
- * After connecting, use `environment.publishTools()` to register tools,
6080
- * 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.
6081
5992
  *
6082
5993
  * @param options - Connection options
6083
5994
  * @returns An active environment session
@@ -6094,10 +6005,12 @@ var Granular = class {
6094
6005
  * user,
6095
6006
  * });
6096
6007
  *
6097
- * // Publish tools
6098
- * await environment.publishTools([
6099
- * { name: 'greet', description: 'Say hello', inputSchema: {}, handler: async () => 'Hello!' },
6100
- * ]);
6008
+ * await granular.registerEffect('my-sandbox', {
6009
+ * name: 'greet',
6010
+ * description: 'Say hello',
6011
+ * inputSchema: { type: 'object', properties: {} },
6012
+ * handler: async () => 'Hello!',
6013
+ * });
6101
6014
  *
6102
6015
  * // Submit job
6103
6016
  * const job = await environment.submitJob(`
@@ -6119,10 +6032,18 @@ var Granular = class {
6119
6032
  subjectId: options.user.subjectId,
6120
6033
  permissionProfileId: null
6121
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
+ });
6122
6043
  const client = new WSClient({
6123
- url: this.apiUrl,
6124
- sessionId: envData.environmentId,
6125
- token: this.apiKey,
6044
+ url: session.wsUrl,
6045
+ sessionId: session.sessionId,
6046
+ token: session.token,
6126
6047
  tokenProvider: this.tokenProvider,
6127
6048
  WebSocketCtor: this.WebSocketCtor,
6128
6049
  onUnexpectedClose: this.onUnexpectedClose,
@@ -6131,47 +6052,165 @@ var Granular = class {
6131
6052
  await client.connect();
6132
6053
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;
6133
6054
  const environment = new Environment(client, envData, clientId, this.apiKey, graphqlEndpoint);
6134
- if (!this.activeEnvironments.has(sandbox.sandboxId)) {
6135
- this.activeEnvironments.set(sandbox.sandboxId, []);
6136
- }
6137
- this.activeEnvironments.get(sandbox.sandboxId).push(environment);
6138
- environment.on("disconnect", () => {
6139
- const list = this.activeEnvironments.get(sandbox.sandboxId);
6140
- if (list) {
6141
- this.activeEnvironments.set(sandbox.sandboxId, list.filter((e) => e !== environment));
6142
- }
6143
- });
6144
6055
  await environment.hello();
6145
- const effects = this.sandboxEffects.get(sandbox.sandboxId);
6146
- if (effects && effects.size > 0) {
6147
- const effectsList = Array.from(effects.values());
6148
- console.log(`[Granular] Auto-publishing ${effectsList.length} effects for sandbox ${sandbox.sandboxId}`);
6149
- await environment.publishEffects(effectsList);
6150
- }
6151
6056
  return environment;
6152
6057
  }
6058
+ async activateEnvironment(environmentId) {
6059
+ await this.request(`/orchestrator/runtime/environments/${environmentId}/activate`, {
6060
+ method: "POST",
6061
+ body: JSON.stringify({})
6062
+ });
6063
+ }
6153
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
+ }
6154
6203
  /**
6155
6204
  * Register an effect (tool) for a specific sandbox.
6156
- *
6157
- * The effect will be automatically published to:
6158
- * 1. Any currently active environments for this sandbox
6159
- * 2. Any new environments created/connected for this sandbox
6160
- *
6205
+ *
6161
6206
  * @param sandboxNameOrId - The name or ID of the sandbox
6162
6207
  * @param effect - The tool definition and handler
6163
6208
  */
6164
6209
  async registerEffect(sandboxNameOrId, effect) {
6165
6210
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6166
6211
  const sandboxId = sandbox.sandboxId;
6167
- if (!this.sandboxEffects.has(sandboxId)) {
6168
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
6169
- }
6170
- this.sandboxEffects.get(sandboxId).set(effect.name, effect);
6171
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6172
- for (const env of activeEnvs) {
6173
- await env.publishEffect(effect);
6174
- }
6212
+ this.getSandboxEffectMap(sandboxId).set(computeEffectKey(effect), effect);
6213
+ await this.syncSandboxEffectCatalog(sandboxId);
6175
6214
  }
6176
6215
  /**
6177
6216
  * Register multiple effects (tools) for a specific sandbox.
@@ -6181,35 +6220,39 @@ var Granular = class {
6181
6220
  async registerEffects(sandboxNameOrId, effects) {
6182
6221
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6183
6222
  const sandboxId = sandbox.sandboxId;
6184
- if (!this.sandboxEffects.has(sandboxId)) {
6185
- this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
6186
- }
6187
- const map = this.sandboxEffects.get(sandboxId);
6223
+ const map = this.getSandboxEffectMap(sandboxId);
6188
6224
  for (const effect of effects) {
6189
- map.set(effect.name, effect);
6190
- }
6191
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6192
- for (const env of activeEnvs) {
6193
- await env.publishEffects(effects);
6225
+ map.set(computeEffectKey(effect), effect);
6194
6226
  }
6227
+ await this.syncSandboxEffectCatalog(sandboxId);
6195
6228
  }
6196
6229
  /**
6197
6230
  * Unregister an effect from a sandbox.
6198
6231
  *
6199
- * Removes it from the local registry and unpublishes it from
6200
- * all active environments.
6232
+ * Removes it from the local sandbox registry and updates the
6233
+ * sandbox-scoped live catalog.
6201
6234
  */
6202
6235
  async unregisterEffect(sandboxNameOrId, name) {
6203
6236
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6204
6237
  const sandboxId = sandbox.sandboxId;
6205
- const map = this.sandboxEffects.get(sandboxId);
6206
- if (map) {
6207
- map.delete(name);
6238
+ const currentMap = this.sandboxEffects.get(sandboxId);
6239
+ if (!currentMap) {
6240
+ return;
6208
6241
  }
6209
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6210
- for (const env of activeEnvs) {
6211
- 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;
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;
6212
6254
  }
6255
+ await this.syncSandboxEffectCatalog(sandboxId);
6213
6256
  }
6214
6257
  /**
6215
6258
  * Unregister all effects for a sandbox.
@@ -6218,10 +6261,7 @@ var Granular = class {
6218
6261
  const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
6219
6262
  const sandboxId = sandbox.sandboxId;
6220
6263
  this.sandboxEffects.delete(sandboxId);
6221
- const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
6222
- for (const env of activeEnvs) {
6223
- await env.unpublishAllEffects();
6224
- }
6264
+ this.disconnectSandboxEffectHost(sandboxId);
6225
6265
  }
6226
6266
  /**
6227
6267
  * Find a sandbox by name or create it if it doesn't exist
@@ -6257,7 +6297,7 @@ var Granular = class {
6257
6297
  const created = await this.permissionProfiles.create(sandboxId, {
6258
6298
  name: profileName,
6259
6299
  rules: {
6260
- tools: { allow: ["*"] },
6300
+ effects: { allow: ["*"] },
6261
6301
  resources: { allow: ["*"] }
6262
6302
  }
6263
6303
  });