Wormhole

Background job runner for CID models. Jobs are submitted through Stargate's SignalR hub and executed by Wormhole workers. Results and status updates are pushed back to the client in real time.

1Connecting

All Wormhole interactions go through Stargate's SignalR hub. Clients never connect to Wormhole directly.

Endpoints
Production: https://stargate.walterpmoore.com/wh
Development: https://localhost:7161/wh

Manual — JavaScript

Include the SignalR client and build a connection manually:

// CDN (no bundler)
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/6.0.1/signalr.min.js"></script>

const connection = new signalR.HubConnectionBuilder()
  .withUrl("https://stargate.walterpmoore.com/wh")
  .withAutomaticReconnect()
  .build();

await connection.start();
JS

Manual — Python

Install pip install signalrcore before using the examples below.
from signalrcore.hub_connection_builder import HubConnectionBuilder

connection = (
    HubConnectionBuilder()
    .with_url("https://stargate.walterpmoore.com/wh")
    .with_automatic_reconnect({
        "type": "raw",
        "keep_alive_interval": 10,
        "reconnect_interval": 5,
        "max_attempts": 5,
    })
    .build()
)

connection.start()
PY

C# Client

Add the NuGet package Microsoft.AspNetCore.SignalR.Client to your project before using the examples below.
using WalterPMoore.Cid.Central.Wormhole;

var client = new WormholeClient(); // dev: true for localhost
await client.ConnectAsync();
C#

2JavaScript Module

wormhole.js is a thin module that wraps the SignalR connection and handles body-shape conventions automatically. It is the recommended approach for web app integrations.

Module URLhttps://wormhole.walterpmoore.com/wormhole.js · ↓ download
import { WormholeClient } from 'https://wormhole.walterpmoore.com/wormhole.js';

const client = new WormholeClient({ dev: false });
await client.connect();
JS

Pass dev: true to point at localhost, or url to override the hub URL entirely.

Exposed API

MethodReturnsDescription
connect()PromiseConnect to the hub. Must be called before anything else.
disconnect()PromiseGracefully close the connection.
openPortal(source, portalName, options?)Promise<string>Submit a single job. Returns the job ID.
openPortals(source, portals[])Promise<string[]>Submit a sequence of jobs. Returns all job IDs immediately.
queryJobStatus(jobId)Promise<JobStatus|null>Fetch the current status of a job via Stargate.
subscribeToJob(jobId)PromiseSubscribe to updates for a job created outside this connection (e.g. a post-push job).
subscribeToJobs(jobIds[])PromiseSubscribe to multiple jobs at once.
subscribeToJobAndQuery(jobId)Promise<JobStatus|null>Subscribe and return current status atomically. Preferred over separate subscribe + query.
subscribeToJobsAndQuery(jobIds[])Promise<(JobStatus|null)[]>Subscribe to multiple jobs and return all statuses at once.
onJobStatus(handler)thisRegister a handler for status-change events.
onJobComplete(handler)thisRegister a handler for job completion events.
onJobFailed(handler)thisRegister a handler for job failure events.

4C# Client

WormholeClient is a typed C# wrapper around the SignalR connection. It handles body-shape conventions automatically and exposes simple async methods and .NET events for job tracking.

NuGet dependency
Microsoft.AspNetCore.SignalR.Client
using WalterPMoore.Cid.Central.Wormhole;

// Production (default)
var client = new WormholeClient();

// Development
var client = new WormholeClient(dev: true);

// Custom URL
var client = new WormholeClient(url: "https://my-stargate-instance/wh");

await client.ConnectAsync();
C#

Exposed API

MemberTypeDescription
ConnectAsync()TaskConnect to the hub. Must be called before anything else.
DisconnectAsync()TaskGracefully close the connection.
IsConnectedboolWhether the client is currently connected.
OpenPortalAsync(source, portalName, options?)Task<string>Submit a single job. Returns the job ID.
OpenPortalsAsync(source, portals[])Task<IReadOnlyList<string>>Submit a sequence of jobs. Returns all job IDs immediately.
QueryJobStatusAsync(jobId)Task<JobStatus?>Fetch the current status of a job via Stargate.
SubscribeToJobAsync(jobId)TaskSubscribe to updates for a job created outside this connection.
SubscribeToJobsAsync(jobIds)TaskSubscribe to multiple jobs at once.
SubscribeToJobAndQueryAsync(jobId)Task<JobStatus?>Subscribe and return current status atomically.
SubscribeToJobsAndQueryAsync(jobIds)Task<JobStatus?[]>Subscribe to multiple jobs and return all statuses at once.
OnJobStatusevent Action<string, JobStatus>Fired when a job transitions to a new status.
OnJobCompleteevent Action<string, object?>Fired when a job completes. Result is job-type specific and may be null.
OnJobFailedevent Action<string, string>Fired when a job fails. Second argument is the error message.

4Opening Portals

A portal is a job request tied to a CID Central model. The source is a workspace/branch/revision path. Tool portals (those under Tool/) apply a CID Tools operation to the model. Other portals have their own flat input shape.

When opening multiple portals against the same source, use OpenPortals. The model is downloaded once and passed through each job in sequence — no redundant downloads. All job IDs are returned immediately; sequencing is enforced server-side.

Tool portal body shape: tool options must be nested under an Input key. The module handles this automatically. If calling the hub directly, wrap your options: { "Input": { ... } }

Available portals

PortalTypeDescription
Tool/AuditToolAudits the model for problems. Returns a problem count.
Tool/SupplementToolSupplements and universalizes the model.
Tool/MergeToolMerges another model into this one.
Tool/TransformToolApplies a geometric transformation to the model.
Tool/ConnectivityToolRuns connectivity analysis on the model.
Tool/GuidReferencesToolArms GUID references on the model.
Tool/Supplement/MaterialsToolSupplements materials from the catalog.
Tool/Supplement/SectionsToolSupplements sections from the catalog.
Tool/Columns/JoinToolJoins columns in the model.
Tool/Columns/SplitToolSplits columns in the model.
Tool/UpdateGroupsToolUpdates element groups.
Tool/RemoveRecordsToolRemoves records from the model via merge.
Tool/FrameResultsToolCompiles frame results into the model.
Job/DiffJobCompares the model to a reference and stores the diff. See note below.
Job/FastGeoJobGenerates a 3D geometry preview and stores it alongside the model.
Job/ExampleIdleJobWaits for a configurable duration. For testing purposes.
Job/Diff — reference model
By default, Job/Diff compares the primary model against the previous revision on the same branch (revision - 1). If the primary model is revision 0, or if there is no previous revision, the diff is skipped gracefully.

To compare against a specific revision instead, pass a comparePortal in the options:
{
  "comparePortal": { "source": "myproject/main/2" }
}
comparePortal.source accepts the same formats as the primary source — a workspace/branch/revision path or a front desk GUID. When specified alongside a batch sequence, the compare portal is always downloaded fresh and is not subject to the continuation cache.
Module
Manual
Python
C#
// Single job
const jobId = await client.openPortal(
                'myproject/main/0',
                'Tool/Supplement',
  {
    isUniversalizeDoubleSections: true,
    isSupplementMaterials:        true,
    isSupplementSections:         true,
    isUniversalize:               true,
  }
);

// Sequence — model downloaded once, jobs run in order
const jobIds = await client.openPortals(
                'myproject/main/0',
  [
    {
      portalName: 'Tool/Supplement',
      options: { isUniversalize: true },
    },
    {
      portalName: 'Tool/Audit',
    },
  ]
);
JS
// Single tool job — note the Input wrapper
const jobId = await connection.invoke(
                'OpenPortal',
                'myproject/main/0',
                'Tool/Supplement',
  {
    Input: {
      isUniversalizeDoubleSections: true,
      isSupplementMaterials:        true,
      isSupplementSections:         true,
      isUniversalize:               true,
    }
  }
);

// Sequence
const jobIds = await connection.invoke(
                'OpenPortals',
                'myproject/main/0',
  [
    { PortalName: 'Tool/Supplement', Body: { Input: { isUniversalize: true } } },
    { PortalName: 'Tool/Audit',      Body: { Input: {} } },
  ]
);
JS
// Single job
string jobId = await client.OpenPortalAsync(
                "myproject/main/0",
                "Tool/Supplement",
                new {
        isUniversalize        = true,
        isSupplementMaterials = true,
        isSupplementSections  = true,
    });

// Sequence — model downloaded once, jobs run in order
IReadOnlyList<string> jobIds = await client.OpenPortalsAsync(
                "myproject/main/0",
    [
                new("Tool/Supplement", new { isUniversalize = true }),
                new("Tool/Audit"),
    ]);
C#
# Single job
job_id = connection.send(
                "OpenPortal",
    [
                "myproject/main/0",
                "Tool/Supplement",
        {
                "Input": {
                "isUniversalize": True,
                "isSupplementMaterials": True,
                "isSupplementSections": True,
            }
        },
    ],
)

# Sequence — model downloaded once, jobs run in order
job_ids = connection.send(
                "OpenPortals",
    [
                "myproject/main/0",
        [
            {"PortalName": "Tool/Supplement", "Body": {"Input": {"isUniversalize": True}}},
            {"PortalName": "Tool/Audit",      "Body": {"Input": {}}},
        ],
    ],
)
PY

5Querying Status

Query a job's current status immediately after submission to avoid a race condition where a fast job completes before SignalR event handlers are registered. Status values are the JobStatus enum serialized as strings: Created · Running · Completed · Failed · Canceled

Module
Manual
Python
C#
const status = await client.queryJobStatus(jobId);
console.log(status); // e.g. "Running"
JS
const status = await connection.invoke('QueryJobStatus', jobId);
console.log(status); // e.g. "Running"
JS
JobStatus? status = await client.QueryJobStatusAsync(jobId);
Console.WriteLine(status); // e.g. Running
C#
status = connection.send("QueryJobStatus", [job_id])
print(status)  # e.g. "Running"
PY

6Callbacks

Three events are pushed from Stargate to the client during a job's lifetime. Register handlers before submitting jobs.

EventArgumentsDescription
ReceiveJobStatusjobId, statusFired when a job transitions to Running, then again to Completed or Failed.
ReceiveJobCompletejobId, resultFired when a job completes. result is job-type specific and may be null.
ReceiveJobFailedjobId, errorFired when a job fails. error is a string description.
Module
Manual
Python
C#
client
  .onJobStatus((jobId, status) => {
    console.log(`Job ${jobId}: ${status}`);
  })
  .onJobComplete((jobId, result) => {
    console.log(`Job ${jobId} complete`, result);
  })
  .onJobFailed((jobId, error) => {
    console.error(`Job ${jobId} failed: ${error}`);
  });

// Then submit
const jobId = await client.openPortal('myproject/main/0', 'Tool/Supplement');
await client.queryJobStatus(jobId); // seed initial status
JS
connection.on('ReceiveJobStatus', (jobId, status) => {
  console.log(`Job ${jobId}: ${status}`);
});

connection.on('ReceiveJobComplete', (jobId, result) => {
  console.log(`Job ${jobId} complete`, result);
});

connection.on('ReceiveJobFailed', (jobId, error) => {
  console.error(`Job ${jobId} failed: ${error}`);
});

// Then submit
const jobId = await connection.invoke('OpenPortal', 'myproject/main/0', 'Tool/Supplement', { Input: {} });
const status = await connection.invoke('QueryJobStatus', jobId); // seed initial status
JS
client.OnJobStatus   += (jobId, status) => Console.WriteLine($"{jobId}: {status}");
client.OnJobComplete += (jobId, result) => Console.WriteLine($"{jobId} complete");
client.OnJobFailed   += (jobId, error)  => Console.WriteLine($"{jobId} failed: {error}");

// Register handlers, then submit
string jobId = await client.OpenPortalAsync("myproject/main/0", "Tool/Supplement");
await client.QueryJobStatusAsync(jobId); // seed initial status
C#
connection.on("ReceiveJobStatus", lambda args:
                print(f"Job {args[0]}: {args[1]}"))

connection.on("ReceiveJobComplete", lambda args:
                print(f"Job {args[0]} complete", args[1]))

connection.on("ReceiveJobFailed", lambda args:
                print(f"Job {args[0]} failed: {args[1]}"))

# Register handlers before starting the connection, then submit
connection.start()
job_id = connection.send("OpenPortal", ["myproject/main/0", "Tool/Supplement", {"Input": {}}])
connection.send("QueryJobStatus", [job_id])  # seed initial status
PY

7Subscribing to External Jobs

Jobs created outside your SignalR connection — such as post-push jobs launched server-side by PushModel — are not automatically routed to any client. To receive updates for them, subscribe using the job IDs returned in the PostJobIds field of the push response.

Call queryJobStatus immediately after subscribing to seed the current state, in case the job is already running or finished by the time you subscribe.

Module
Manual
Python
C#
// After a PushModel call that returns PostJobIds:
const { postJobIds } = pushResponse;

// Subscribe and get current statuses atomically — single call per job.
// Use the returned statuses to seed your UI immediately.
const statuses = await client.subscribeToJobsAndQuery(postJobIds);

postJobIds.forEach((id, i) => {
  console.log(id, statuses[i]); // e.g. "Running", "Completed"
});

// From here, onJobStatus / onJobComplete / onJobFailed fire normally.
JS
// After a PushModel call that returns PostJobIds:
const { postJobIds } = pushResponse;

// SubscribeToJobAndQuery registers the connection and returns current
// status atomically, closing the race window.
const statuses = await Promise.all(
  postJobIds.map(id => connection.invoke('SubscribeToJobAndQuery', id))
);

// From here, ReceiveJobStatus / ReceiveJobComplete / ReceiveJobFailed fire normally.
JS
// After a PushModel call that returns PostJobIds:
JobStatus?[] statuses = await client.SubscribeToJobsAndQueryAsync(postJobIds);

foreach (var (id, status) in postJobIds.Zip(statuses))
{
    Console.WriteLine($"{id}: {status}");
}

// From here, OnJobStatus / OnJobComplete / OnJobFailed fire normally.
C#
# After a PushModel call that returns post_job_ids:
import concurrent.futures

def subscribe_and_query(job_id):
    status = connection.send("SubscribeToJobAndQuery", [job_id])
                print(f"{job_id}: {status}")

with concurrent.futures.ThreadPoolExecutor() as executor:
    executor.map(subscribe_and_query, post_job_ids)

# From here, ReceiveJobStatus / ReceiveJobComplete / ReceiveJobFailed fire normally.
PY

8PushModel with Auto-Tasks

When pushing a model to CID Central, you can request that one or more Wormhole jobs run against the uploaded revision immediately after it is stored. The jobs are returned in the response as PostJobIds so you can subscribe to and track their progress.

Jobs specified in PostJobs run after any server-side default jobs (see Default Jobs), all as a single sequenced batch — the model is only downloaded once.

Endpoint   POST /api/cid/central/Model/{workspace}/{branch}/{revision?}

PushArguments fields

FieldTypeDescription
PushMessagestring?A message describing the purpose of the push.
IsHardOverwritebool?Completely replace the existing revision rather than merging.
NoMergeJustUseIncomingbool?Skip the existing model entirely and push to the next revision.
IsCheckForMergebool?If false, create a new revision even when the merge is a no-op. Default true.
MergeOptionsobject?Fine-grained merge configuration.
SkipDefaultPostJobsbool?Suppress server-configured default jobs. Caller-specified PostJobs still run.
PostJobsarray?Ordered list of jobs to run after the push. See shape below.

Request

// POST /api/cid/central/Model/myproject/main
{
        "Model": { /* ... CID model ... */ },
        "PushArguments": {
        "PushMessage": "Weekly model update",
        "PostJobs": [
      {
        "PortalName": "Tool/Supplement",
        "Options": {
        "isUniversalize": true,
        "isSupplementMaterials": true,
        "isSupplementSections": true
        }
      },
      {
        "PortalName": "Tool/Audit"
      }
    ]
  }
}
JSON

Response

{
        "pushAction": "Merge",
        "model":      { /* uploaded CidCentralModel */ },
        "direction":  "Upload",
        "postJobIds": [
        "a1b2c3d4-...",  // Job/Diff (default)
        "e5f6a7b8-...",  // Tool/Supplement (caller)
        "c9d0e1f2-..."   // Tool/Audit (caller)
  ]
}
JSON

Tracking the jobs

Pass PostJobIds to subscribeToJobs after connecting to the hub. See Subscribing to External Jobs for the full pattern.

9Default Jobs

Default jobs run automatically after every successful push, before any caller-specified PostJobs. They are defined server-side in WormholeDispatcher.DefaultJobs and require no action from the caller. Pass SkipDefaultPostJobs: true in PushArguments to suppress them.

Current default jobs

PortalOptionsDescription
Job/Diff Compares the pushed revision to the previous revision on the same branch and stores the diff. Skipped gracefully if the revision is the first on its branch.
To add or remove default jobs, update WormholeDispatcher.DefaultJobs in Stargate. No schema changes or client updates are required.

Execution order

For a push with default jobs A and B and caller jobs C and D, the full sequence is A → B → C → D, all sharing a single model download via continuation. If SkipDefaultPostJobs is true, only C → D run.