Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions typescript-sdk/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
Dist
dist
Comment thread
nk-ag marked this conversation as resolved.
76 changes: 76 additions & 0 deletions typescript-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ExosphereHost TypeScript SDK

This package provides a TypeScript interface to interact with the ExosphereHost state manager. It mirrors the functionality of the Python SDK, offering utilities to manage graphs and trigger executions.

It also ships a lightweight runtime for executing `BaseNode` subclasses and utility signals for advanced control flow.

## Installation

```bash
npm install exospherehost
```

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Usage

```typescript
import { StateManager, GraphNode, TriggerState } from 'exospherehost';

const sm = new StateManager('my-namespace', {
stateManagerUri: 'https://state-manager.example.com',
key: 'api-key'
});

const nodes: GraphNode[] = [
{
node_name: 'Start',
identifier: 'start-node',
inputs: {},
next_nodes: ['end-node']
},
{
node_name: 'End',
identifier: 'end-node',
inputs: {},
next_nodes: []
}
];
Comment thread
nk-ag marked this conversation as resolved.
Outdated

await sm.upsertGraph('sample-graph', nodes, {});

const trigger: TriggerState = {
identifier: 'demo',
inputs: { foo: 'bar' }
};

await sm.trigger('sample-graph', trigger);
Comment thread
nk-ag marked this conversation as resolved.
Outdated
```

### Defining Nodes and Running the Runtime

```typescript
import { BaseNode, Runtime } from 'exospherehost';
import { z } from 'zod';

class ExampleNode extends BaseNode<typeof ExampleNode.Inputs, typeof ExampleNode.Outputs> {
Comment thread
nk-ag marked this conversation as resolved.
Outdated
static Inputs = z.object({ message: z.string() });
static Outputs = z.object({ result: z.string() });
static Secrets = z.object({});

async execute() {
return { result: this.inputs.message.toUpperCase() };
}
}

const runtime = new Runtime('my-namespace', 'example-runtime', [ExampleNode], {
stateManagerUri: 'https://state-manager.example.com',
key: 'api-key'
});

await runtime.start();
```
Comment thread
nk-ag marked this conversation as resolved.
Outdated

Nodes can also throw `PruneSignal` to drop a state or `ReQueueAfterSignal` to requeue it after a delay.

## License

MIT
8 changes: 8 additions & 0 deletions typescript-sdk/exospherehost/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export * from './types.js';
export * from './models.js';
export * from './stateManager.js';
export * from './node/index.js';
export * from './runtime.js';
export * from './signals.js';
export * from './logger.js';
export * from './utils.js';
Comment on lines +1 to +8
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

LGTM for public barrel.

Re-exports look coherent across types, models, runtime, signals, and utils.

Ensure package.json “exports” and “types” map to built ESM/CJS (e.g., dist/index.js + dist/index.d.ts) so consumers can import { Runtime } from '@exosphere/sdk' without pathing to files.

🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/index.ts lines 1-8, the barrel exports are
correct but package.json currently may not point consumers to the built ESM/CJS
artifacts; update package.json so the package "exports" and type entry point map
to the compiled outputs (e.g., point "module" or appropriate export conditions
to dist/index.js for ESM, "main" or commonjs export to dist/index.cjs or
dist/index.js for CJS, and "types" to dist/index.d.ts), ensure the build step
emits those files (adjust tsconfig to emitDeclarationOnly or declaration: true
and set outDir to dist), and add proper export conditions in "exports" for "./"
and "./package.json" so consumers can import { Runtime } from '@exosphere/sdk'
without deep paths.

70 changes: 70 additions & 0 deletions typescript-sdk/exospherehost/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3
}

export class Logger {
private static instance: Logger;
private level: LogLevel;
private isDisabled: boolean;

private constructor() {
this.level = this.getLogLevelFromEnv();
this.isDisabled = process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING === 'true';
}
Comment on lines +13 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Boolean env parsing is too strict

Support common true/false forms (true/1/yes/on).

Apply this diff:

-    this.isDisabled = process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING === 'true';
+    const v = (process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING || '').toString().toLowerCase();
+    this.isDisabled = ['true','1','yes','on'].includes(v);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private constructor() {
this.level = this.getLogLevelFromEnv();
this.isDisabled = process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING === 'true';
}
private constructor() {
this.level = this.getLogLevelFromEnv();
const v = (process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING || '').toString().toLowerCase();
this.isDisabled = ['true','1','yes','on'].includes(v);
}


public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
Comment on lines +8 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Expose runtime controls for tests and dynamic configs

Provide setLevel/setDisabled to avoid relying on process.env at import-time only.

   private level: LogLevel;
   private isDisabled: boolean;
@@
   }
 
+  public setLevel(level: LogLevel) { this.level = level; }
+  public setDisabled(disabled: boolean) { this.isDisabled = disabled; }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export class Logger {
private static instance: Logger;
private level: LogLevel;
private isDisabled: boolean;
private constructor() {
this.level = this.getLogLevelFromEnv();
this.isDisabled = process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING === 'true';
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
export class Logger {
private static instance: Logger;
private level: LogLevel;
private isDisabled: boolean;
private constructor() {
this.level = this.getLogLevelFromEnv();
this.isDisabled = process.env.EXOSPHERE_DISABLE_DEFAULT_LOGGING === 'true';
}
public setLevel(level: LogLevel) { this.level = level; }
public setDisabled(disabled: boolean) { this.isDisabled = disabled; }
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
}
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/logger.ts around lines 8 to 23, the Logger
currently reads log level and disabled flag only at construction from
process.env, preventing tests and runtime code from adjusting them; add public
setLevel(level: LogLevel) and setDisabled(disabled: boolean) methods that update
the instance's level and isDisabled fields at runtime, keep the constructor
behavior of initializing from env, and ensure getInstance continues to return
the single Logger so callers (including tests) can call setLevel/setDisabled to
alter behavior without modifying environment variables.


private getLogLevelFromEnv(): LogLevel {
const levelName = (process.env.EXOSPHERE_LOG_LEVEL || 'INFO').toUpperCase();
switch (levelName) {
case 'DEBUG': return LogLevel.DEBUG;
case 'INFO': return LogLevel.INFO;
case 'WARN': return LogLevel.WARN;
case 'ERROR': return LogLevel.ERROR;
default: return LogLevel.INFO;
}
}

private shouldLog(level: LogLevel): boolean {
return !this.isDisabled && level >= this.level;
}

private formatMessage(level: string, name: string, message: string): string {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
return `${timestamp} | ${level} | ${name} | ${message}`;
}
Comment on lines +40 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Preserve milliseconds in timestamps

Current substring drops ms. Keep full ISO timestamp for better tracing.

-    const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
-    return `${timestamp} | ${level} | ${name} | ${message}`;
+    const timestamp = new Date().toISOString(); // includes milliseconds, UTC
+    return `${timestamp} | ${level} | ${name} | ${message}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private formatMessage(level: string, name: string, message: string): string {
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
return `${timestamp} | ${level} | ${name} | ${message}`;
}
private formatMessage(level: string, name: string, message: string): string {
const timestamp = new Date().toISOString(); // includes milliseconds, UTC
return `${timestamp} | ${level} | ${name} | ${message}`;
}
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/logger.ts around lines 40 to 43, the timestamp
generation currently truncates milliseconds via substring(0,19); remove the
substring call so the full ISO timestamp (including milliseconds) is
preserved—e.g., use new Date().toISOString().replace('T', ' ') (or otherwise
avoid slicing) and return the formatted string as before.


public debug(name: string, message: string): void {
if (this.shouldLog(LogLevel.DEBUG)) {
console.debug(this.formatMessage('DEBUG', name, message));
}
}

public info(name: string, message: string): void {
if (this.shouldLog(LogLevel.INFO)) {
console.info(this.formatMessage('INFO', name, message));
}
}

public warn(name: string, message: string): void {
if (this.shouldLog(LogLevel.WARN)) {
console.warn(this.formatMessage('WARN', name, message));
}
}

public error(name: string, message: string): void {
if (this.shouldLog(LogLevel.ERROR)) {
console.error(this.formatMessage('ERROR', name, message));
}
}
Comment on lines +45 to +67
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Allow non-string payloads and Error objects

Broaden message type and include stack traces automatically.

Apply this diff:

-  public debug(name: string, message: string): void {
+  private toText(payload: unknown): string {
+    if (payload instanceof Error) return payload.stack || payload.message;
+    if (typeof payload === 'string') return payload;
+    try { return JSON.stringify(payload); } catch { return String(payload); }
+  }
+
+  public debug(name: string, message: unknown): void {
     if (this.shouldLog(LogLevel.DEBUG)) {
-      console.debug(this.formatMessage('DEBUG', name, message));
+      console.debug(this.formatMessage('DEBUG', name, this.toText(message)));
     }
   }
 
-  public info(name: string, message: string): void {
+  public info(name: string, message: unknown): void {
     if (this.shouldLog(LogLevel.INFO)) {
-      console.info(this.formatMessage('INFO', name, message));
+      console.info(this.formatMessage('INFO', name, this.toText(message)));
     }
   }
 
-  public warn(name: string, message: string): void {
+  public warn(name: string, message: unknown): void {
     if (this.shouldLog(LogLevel.WARN)) {
-      console.warn(this.formatMessage('WARN', name, message));
+      console.warn(this.formatMessage('WARN', name, this.toText(message)));
     }
   }
 
-  public error(name: string, message: string): void {
+  public error(name: string, message: unknown): void {
     if (this.shouldLog(LogLevel.ERROR)) {
-      console.error(this.formatMessage('ERROR', name, message));
+      console.error(this.formatMessage('ERROR', name, this.toText(message)));
     }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public debug(name: string, message: string): void {
if (this.shouldLog(LogLevel.DEBUG)) {
console.debug(this.formatMessage('DEBUG', name, message));
}
}
public info(name: string, message: string): void {
if (this.shouldLog(LogLevel.INFO)) {
console.info(this.formatMessage('INFO', name, message));
}
}
public warn(name: string, message: string): void {
if (this.shouldLog(LogLevel.WARN)) {
console.warn(this.formatMessage('WARN', name, message));
}
}
public error(name: string, message: string): void {
if (this.shouldLog(LogLevel.ERROR)) {
console.error(this.formatMessage('ERROR', name, message));
}
}
private toText(payload: unknown): string {
if (payload instanceof Error) return payload.stack || payload.message;
if (typeof payload === 'string') return payload;
try { return JSON.stringify(payload); } catch { return String(payload); }
}
public debug(name: string, message: unknown): void {
if (this.shouldLog(LogLevel.DEBUG)) {
console.debug(this.formatMessage('DEBUG', name, this.toText(message)));
}
}
public info(name: string, message: unknown): void {
if (this.shouldLog(LogLevel.INFO)) {
console.info(this.formatMessage('INFO', name, this.toText(message)));
}
}
public warn(name: string, message: unknown): void {
if (this.shouldLog(LogLevel.WARN)) {
console.warn(this.formatMessage('WARN', name, this.toText(message)));
}
}
public error(name: string, message: unknown): void {
if (this.shouldLog(LogLevel.ERROR)) {
console.error(this.formatMessage('ERROR', name, this.toText(message)));
}
}
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/logger.ts around lines 45 to 67, the log methods
only accept string messages and therefore drop non-string payloads and Error
stack traces; change the public debug/info/warn/error signatures to accept a
broader type (e.g., unknown | Error), then normalize the payload before passing
to formatMessage: if the value is an Error, use its stack (fallback to message),
if it's a non-string object, JSON.stringify it (with safe/circular handling or a
replacer), and otherwise convert toString(); update formatMessage to accept a
string already-normalized message and ensure the Error stack gets logged
automatically.

}

export const logger = Logger.getInstance();
159 changes: 159 additions & 0 deletions typescript-sdk/exospherehost/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { z } from 'zod';

// Unites Strategy Enum
export enum UnitesStrategyEnum {
ALL_SUCCESS = 'ALL_SUCCESS',
ALL_DONE = 'ALL_DONE'
}

// Unites Model
export const UnitesModel = z.object({
identifier: z.string().describe('Identifier of the node'),
strategy: z.nativeEnum(UnitesStrategyEnum).default(UnitesStrategyEnum.ALL_SUCCESS).describe('Strategy of the unites')
});

export type UnitesModel = z.infer<typeof UnitesModel>;

// Graph Node Model
export const GraphNodeModel = z.object({
node_name: z.string()
.min(1, 'Node name cannot be empty')
.transform((val: string) => val.trim())
.refine((val: string) => val.length > 0, 'Node name cannot be empty')
.describe('Name of the node'),
namespace: z.string().describe('Namespace of the node'),
identifier: z.string()
.min(1, 'Node identifier cannot be empty')
.transform((val: string) => val.trim())
.refine((val: string) => val.length > 0, 'Node identifier cannot be empty')
Comment thread
nk-ag marked this conversation as resolved.
.refine((val: string) => val !== 'store', 'Node identifier cannot be reserved word \'store\'')
.describe('Identifier of the node'),
inputs: z.record(z.unknown()).default({}).describe('Inputs of the node'),
next_nodes: z.array(z.string())
.transform((nodes: string[]) => nodes.map((node: string) => node.trim()))
.refine((nodes: string[]) => {
const errors: string[] = [];
const identifiers = new Set<string>();

for (const node of nodes) {
if (node === '') {
errors.push('Next node identifier cannot be empty');
continue;
}
if (identifiers.has(node)) {
errors.push(`Next node identifier ${node} is not unique`);
continue;
}
identifiers.add(node);
}

if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
return nodes;
})
.optional()
Comment on lines +33 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Use superRefine for per-item errors; avoid throwing inside refine.

Throwing breaks Zod’s issue reporting. Return issues via context instead.

-  next_nodes: z.array(z.string())
-    .transform((nodes: string[]) => nodes.map((node: string) => node.trim()))
-    .refine((nodes: string[]) => {
-      const errors: string[] = [];
-      const identifiers = new Set<string>();
-      
-      for (const node of nodes) {
-        if (node === '') {
-          errors.push('Next node identifier cannot be empty');
-          continue;
-        }
-        if (identifiers.has(node)) {
-          errors.push(`Next node identifier ${node} is not unique`);
-          continue;
-        }
-        identifiers.add(node);
-      }
-      
-      if (errors.length > 0) {
-        throw new Error(errors.join('\n'));
-      }
-      return nodes;
-    })
+  next_nodes: z.array(z.string())
+    .transform((nodes: string[]) => nodes.map((node: string) => node.trim()))
+    .superRefine((nodes, ctx) => {
+      const seen = new Set<string>();
+      nodes.forEach((node, idx) => {
+        if (node === '') {
+          ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Next node identifier cannot be empty', path: [idx] });
+        } else if (seen.has(node)) {
+          ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Next node identifier '${node}' is not unique`, path: [idx] });
+        } else {
+          seen.add(node);
+        }
+      });
+    })

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/models.ts around lines 33 to 55, the current
.refine implementation throws an Error for per-item validation which breaks
Zod's issue reporting; replace .refine with .superRefine((nodes, ctx) => { ...
}) and inside iterate trimmed nodes, for each empty or duplicate node call
ctx.addIssue(...) with appropriate message and path (index) instead of pushing
to an errors array or throwing, then return nothing (Zod collects issues); keep
the .transform and .optional chaining intact.

.describe('Next nodes to execute'),
unites: UnitesModel
.transform((unites: z.infer<typeof UnitesModel>) => ({
identifier: unites.identifier.trim(),
strategy: unites.strategy
}))
.refine((unites: { identifier: string; strategy: UnitesStrategyEnum }) => unites.identifier.length > 0, 'Unites identifier cannot be empty')
.optional()
.describe('Unites of the node')
});
Comment on lines +57 to +65
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Trim and validate Unites.identifier in schema; drop extra transform+refine at GraphNode.

-export const UnitesModel = z.object({
-  identifier: z.string().describe('Identifier of the node'),
+export const UnitesModel = z.object({
+  identifier: z.string().trim().min(1, 'Unites identifier cannot be empty').describe('Identifier of the node'),
   strategy: z.nativeEnum(UnitesStrategyEnum).default(UnitesStrategyEnum.ALL_SUCCESS).describe('Strategy of the unites')
 });
@@
-  unites: UnitesModel
-    .transform((unites: z.infer<typeof UnitesModel>) => ({
-      identifier: unites.identifier.trim(),
-      strategy: unites.strategy
-    }))
-    .refine((unites: { identifier: string; strategy: UnitesStrategyEnum }) => unites.identifier.length > 0, 'Unites identifier cannot be empty')
-    .optional()
+  unites: UnitesModel.optional()
     .describe('Unites of the node')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
unites: UnitesModel
.transform((unites: z.infer<typeof UnitesModel>) => ({
identifier: unites.identifier.trim(),
strategy: unites.strategy
}))
.refine((unites: { identifier: string; strategy: UnitesStrategyEnum }) => unites.identifier.length > 0, 'Unites identifier cannot be empty')
.optional()
.describe('Unites of the node')
});
export const UnitesModel = z.object({
identifier: z.string().trim().min(1, 'Unites identifier cannot be empty').describe('Identifier of the node'),
strategy: z.nativeEnum(UnitesStrategyEnum).default(UnitesStrategyEnum.ALL_SUCCESS).describe('Strategy of the unites')
});
unites: UnitesModel.optional()
.describe('Unites of the node')
});
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/models.ts around lines 57 to 65, the trimming
and non-empty validation for Unites.identifier are currently applied via an
extra transform+refine on the GraphNode definition; move those concerns into the
UnitesModel itself and remove the duplicate transform+refine from the GraphNode.
Update UnitesModel to apply a transform that trims identifier and a refine that
ensures identifier.length > 0 (with an appropriate error message), then simplify
the GraphNode schema to reference UnitesModel.optional().describe('Unites of the
node') without its own transform/refine.


export type GraphNodeModel = z.infer<typeof GraphNodeModel>;

// Retry Strategy Enum
export enum RetryStrategyEnum {
EXPONENTIAL = 'EXPONENTIAL',
EXPONENTIAL_FULL_JITTER = 'EXPONENTIAL_FULL_JITTER',
EXPONENTIAL_EQUAL_JITTER = 'EXPONENTIAL_EQUAL_JITTER',
LINEAR = 'LINEAR',
LINEAR_FULL_JITTER = 'LINEAR_FULL_JITTER',
LINEAR_EQUAL_JITTER = 'LINEAR_EQUAL_JITTER',
FIXED = 'FIXED',
FIXED_FULL_JITTER = 'FIXED_FULL_JITTER',
FIXED_EQUAL_JITTER = 'FIXED_EQUAL_JITTER'
}

// Retry Policy Model
export const RetryPolicyModel = z.object({
max_retries: z.number().int().min(0).default(3).describe('The maximum number of retries'),
strategy: z.nativeEnum(RetryStrategyEnum).default(RetryStrategyEnum.EXPONENTIAL).describe('The method of retry'),
backoff_factor: z.number().int().positive().default(2000).describe('The backoff factor in milliseconds (default: 2000 = 2 seconds)'),
exponent: z.number().int().positive().default(2).describe('The exponent for the exponential retry strategy'),
max_delay: z.number().int().positive().optional().describe('The maximum delay in milliseconds (no default limit when None)')
});
Comment on lines +84 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Doc nit: “None” → “undefined”.

-  max_delay: z.number().int().positive().optional().describe('The maximum delay in milliseconds (no default limit when None)')
+  max_delay: z.number().int().positive().optional().describe('The maximum delay in milliseconds (no default limit when undefined)')
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
max_retries: z.number().int().min(0).default(3).describe('The maximum number of retries'),
strategy: z.nativeEnum(RetryStrategyEnum).default(RetryStrategyEnum.EXPONENTIAL).describe('The method of retry'),
backoff_factor: z.number().int().positive().default(2000).describe('The backoff factor in milliseconds (default: 2000 = 2 seconds)'),
exponent: z.number().int().positive().default(2).describe('The exponent for the exponential retry strategy'),
max_delay: z.number().int().positive().optional().describe('The maximum delay in milliseconds (no default limit when None)')
});
max_retries: z.number().int().min(0).default(3).describe('The maximum number of retries'),
strategy: z.nativeEnum(RetryStrategyEnum).default(RetryStrategyEnum.EXPONENTIAL).describe('The method of retry'),
backoff_factor: z.number().int().positive().default(2000).describe('The backoff factor in milliseconds (default: 2000 = 2 seconds)'),
exponent: z.number().int().positive().default(2).describe('The exponent for the exponential retry strategy'),
max_delay: z.number().int().positive().optional().describe('The maximum delay in milliseconds (no default limit when undefined)')
});
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/models.ts around lines 84 to 89, the schema
description for max_delay uses the term "None" which is not idiomatic in
TypeScript; update the descriptive string to use "undefined" instead (e.g., "no
default limit when undefined") so the docs match TS conventions and intent.


export type RetryPolicyModel = z.infer<typeof RetryPolicyModel>;

// Store Config Model
export const StoreConfigModel = z.object({
required_keys: z.array(z.string())
.transform((keys: string[]) => keys.map((key: string) => key.trim()))
.refine((keys: string[]) => {
const errors: string[] = [];
const keySet = new Set<string>();

for (const key of keys) {
if (key === '') {
errors.push('Key cannot be empty or contain only whitespace');
continue;
}
if (key.includes('.')) {
errors.push(`Key '${key}' cannot contain '.' character`);
continue;
}
if (keySet.has(key)) {
errors.push(`Key '${key}' is duplicated`);
continue;
}
keySet.add(key);
}

if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
return keys;
})
.default([])
.describe('Required keys of the store'),
Comment on lines +95 to +123
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick

Use superRefine instead of throwing inside refine for required_keys.

-  required_keys: z.array(z.string())
-    .transform((keys: string[]) => keys.map((key: string) => key.trim()))
-    .refine((keys: string[]) => {
-      const errors: string[] = [];
-      const keySet = new Set<string>();
-      
-      for (const key of keys) {
-        if (key === '') {
-          errors.push('Key cannot be empty or contain only whitespace');
-          continue;
-        }
-        if (key.includes('.')) {
-          errors.push(`Key '${key}' cannot contain '.' character`);
-          continue;
-        }
-        if (keySet.has(key)) {
-          errors.push(`Key '${key}' is duplicated`);
-          continue;
-        }
-        keySet.add(key);
-      }
-      
-      if (errors.length > 0) {
-        throw new Error(errors.join('\n'));
-      }
-      return keys;
-    })
+  required_keys: z.array(z.string())
+    .transform((keys: string[]) => keys.map((key: string) => key.trim()))
+    .superRefine((keys, ctx) => {
+      const seen = new Set<string>();
+      keys.forEach((key, idx) => {
+        if (key === '') ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Key cannot be empty or contain only whitespace', path: [idx] });
+        if (key.includes('.')) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Key '${key}' cannot contain '.' character`, path: [idx] });
+        if (seen.has(key)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Key '${key}' is duplicated`, path: [idx] });
+        seen.add(key);
+      });
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
required_keys: z.array(z.string())
.transform((keys: string[]) => keys.map((key: string) => key.trim()))
.refine((keys: string[]) => {
const errors: string[] = [];
const keySet = new Set<string>();
for (const key of keys) {
if (key === '') {
errors.push('Key cannot be empty or contain only whitespace');
continue;
}
if (key.includes('.')) {
errors.push(`Key '${key}' cannot contain '.' character`);
continue;
}
if (keySet.has(key)) {
errors.push(`Key '${key}' is duplicated`);
continue;
}
keySet.add(key);
}
if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
return keys;
})
.default([])
.describe('Required keys of the store'),
required_keys: z.array(z.string())
.transform((keys: string[]) => keys.map((key: string) => key.trim()))
.superRefine((keys, ctx) => {
const seen = new Set<string>();
keys.forEach((key, idx) => {
if (key === '') ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Key cannot be empty or contain only whitespace', path: [idx] });
if (key.includes('.')) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Key '${key}' cannot contain '.' character`, path: [idx] });
if (seen.has(key)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Key '${key}' is duplicated`, path: [idx] });
seen.add(key);
});
})
.default([])
.describe('Required keys of the store'),
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/models.ts around lines 95 to 123, the current
.refine handler throws an Error when validating required_keys which is incorrect
for Zod; replace the .refine(...) implementation with .superRefine((keys, ctx)
=> { ... }) and for each validation failure call ctx.addIssue with an
appropriate code/message/path instead of throwing, ensuring you still trim keys
beforehand and preserve the default([]) and describe metadata.

default_values: z.record(z.string())
.transform((values: Record<string, string>) => {
const errors: string[] = [];
const keySet = new Set<string>();
const normalizedDict: Record<string, string> = {};

for (const [key, value] of Object.entries(values)) {
const trimmedKey = key.trim();

if (trimmedKey === '') {
errors.push('Key cannot be empty or contain only whitespace');
continue;
}
if (trimmedKey.includes('.')) {
errors.push(`Key '${trimmedKey}' cannot contain '.' character`);
continue;
}
if (keySet.has(trimmedKey)) {
errors.push(`Key '${trimmedKey}' is duplicated`);
continue;
}

keySet.add(trimmedKey);
normalizedDict[trimmedKey] = String(value);
Comment thread
nk-ag marked this conversation as resolved.
}

if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
return normalizedDict;
})
.default({})
.describe('Default values of the store')
});

export type StoreConfigModel = z.infer<typeof StoreConfigModel>;
36 changes: 36 additions & 0 deletions typescript-sdk/exospherehost/node/BaseNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { z, ZodTypeAny, ZodObject } from 'zod';

export abstract class BaseNode<I extends ZodObject<any> = ZodObject<any>, O extends ZodObject<any> = ZodObject<any>, S extends ZodObject<any> = ZodObject<any>> {
static Inputs: ZodObject<any> = z.object({});
static Outputs: ZodObject<any> = z.object({});
static Secrets: ZodObject<any> = z.object({});

protected inputs!: z.infer<I>;
protected secrets!: z.infer<S>;

constructor() {
if (this.constructor === BaseNode) {
throw new Error('BaseNode is an abstract class and cannot be instantiated directly');
}
}

async _execute(inputsRaw: unknown, secretsRaw: unknown): Promise<z.infer<O> | z.infer<O>[]> {
const ctor = this.constructor as typeof BaseNode;
const inputs = (ctor.Inputs as I).parse(inputsRaw);
const secrets = (ctor.Secrets as S).parse(secretsRaw);
this.inputs = inputs;
this.secrets = secrets;
const result = await this.execute();
const outputsSchema = ctor.Outputs as O;
Comment on lines +8 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard against reentrancy or avoid mutating instance state.

Concurrent _execute on the same instance can interleave this.inputs/this.secrets. Either pass parsed data to execute (breaking change) or add a simple mutex.

+  private _executing = false;
   async _execute(inputsRaw: unknown, secretsRaw: unknown): Promise<z.infer<O> | z.infer<O>[] | null> {
+    if (this._executing) {
+      throw new Error('BaseNode instances are not re-entrant; create a new instance per execution.');
+    }
+    this._executing = true;
     const ctor = this.constructor as typeof BaseNode;
@@
-    const result = await this.execute();
+    try {
+      const result = await this.execute();
       const outputsSchema = ctor.Outputs as O;
       if (Array.isArray(result)) {
         return result.map(r => outputsSchema.parse(r));
       }
       if (result === null) {
         return null;
       }
       return outputsSchema.parse(result);
+    } finally {
+      this._executing = false;
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
protected inputs!: z.infer<I>;
protected secrets!: z.infer<S>;
constructor() {
if (this.constructor === BaseNode) {
throw new Error('BaseNode is an abstract class and cannot be instantiated directly');
}
}
async _execute(inputsRaw: unknown, secretsRaw: unknown): Promise<z.infer<O> | z.infer<O>[]> {
const ctor = this.constructor as typeof BaseNode;
const inputs = (ctor.Inputs as I).parse(inputsRaw);
const secrets = (ctor.Secrets as S).parse(secretsRaw);
this.inputs = inputs;
this.secrets = secrets;
const result = await this.execute();
const outputsSchema = ctor.Outputs as O;
protected inputs!: z.infer<I>;
protected secrets!: z.infer<S>;
constructor() {
if (this.constructor === BaseNode) {
throw new Error('BaseNode is an abstract class and cannot be instantiated directly');
}
}
private _executing = false;
async _execute(inputsRaw: unknown, secretsRaw: unknown): Promise<z.infer<O> | z.infer<O>[] | null> {
if (this._executing) {
throw new Error('BaseNode instances are not re-entrant; create a new instance per execution.');
}
this._executing = true;
const ctor = this.constructor as typeof BaseNode;
const inputs = (ctor.Inputs as I).parse(inputsRaw);
const secrets = (ctor.Secrets as S).parse(secretsRaw);
this.inputs = inputs;
this.secrets = secrets;
try {
const result = await this.execute();
const outputsSchema = ctor.Outputs as O;
if (Array.isArray(result)) {
return result.map(r => outputsSchema.parse(r));
}
if (result === null) {
return null;
}
return outputsSchema.parse(result);
} finally {
this._executing = false;
}
}
🤖 Prompt for AI Agents
In typescript-sdk/exospherehost/node/BaseNode.ts around lines 8-24, concurrent
calls to _execute mutate this.inputs/this.secrets causing reentrancy issues; add
a simple mutex to serialize executions rather than changing the method
signature. Add a private lock field (e.g. Promise-based or a boolean + queue) on
the instance, acquire the lock at the start of _execute, parse and assign
inputs/secrets, call execute(), then release the lock in a finally block so only
one _execute runs at a time; ensure errors still propagate and the lock is
always released.

if (Array.isArray(result)) {
return result.map(r => outputsSchema.parse(r));
}
if (result === null) {
return null as any;
}
return outputsSchema.parse(result);
}

abstract execute(): Promise<z.infer<O> | z.infer<O>[]>;
Comment thread
nk-ag marked this conversation as resolved.
}

1 change: 1 addition & 0 deletions typescript-sdk/exospherehost/node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BaseNode } from './BaseNode.js';
Loading