MeshWeaver runs C# scripts on a per-Activity Roslyn kernel hosted inside the mesh. Scripts have first-class access to the live IMessageHub, so they can post messages, mutate nodes, and stream results just like compiled hub handlers — but without a recompile cycle.
This page covers three things:
- How to launch a script — from a Code node, from application code, or from an MCP agent.
- How to emit progress — so subscribers see live updates as work unfolds.
- The architecture — how the host hub stays responsive while a script runs.
Launching a script
There are three entry points, all converging on the same Activity hub and the same progress stream.
1. From a Code node — ExecuteScriptRequest
This is the canonical path. Create a Code MeshNode with IsExecutable = true, then post ExecuteScriptRequest to the node's address. The node's hub:
- Creates a fresh
ActivityMeshNode for this run. - Submits the script to the kernel hosted at that activity.
- Responds with the activity's path so the caller can subscribe to progress.
var meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
await meshService.CreateNode(new MeshNode("daily-rollup", "rbuergi")
{
Name = "Daily rollup",
NodeType = "Code",
Content = new CodeConfiguration
{
Code = @"
Log.LogInformation(""Starting rollup..."");
// ... real work ...
Log.LogInformation(""Done — wrote {Count} rows"", 1234);
",
IsExecutable = true,
}
});
// Fire it. The response carries the ActivityLog path for live subscription.
hub.Observe<ExecuteScriptResponse>(
new ExecuteScriptRequest(),
o => o.WithTarget(new Address("rbuergi/daily-rollup")))
.Take(1)
.Subscribe(resp =>
{
var activityPath = resp.Message.ActivityLog!; // e.g. rbuergi/daily-rollup/_Activity/{guid}
// ... subscribe to progress, see next section ...
});
Each call to ExecuteScriptRequest creates a new Activity node in the user's home partition (e.g. rbuergi/_Activity/{guid}) — not nested under the Code node. The originating Code node is preserved on the Activity's MainNode and ActivityLog.HubPath. Two reasons for this placement:
| Reason | Detail |
|---|---|
| Natural activity log | The user's partition root is the right home for every script run, regardless of which Code node triggered it. |
| Reliable routing | Top-level satellite paths route predictably; deeply nested satellite paths require an extra materialization step that races CreateNode → SubscribeRequest and frequently times out. |
Historical runs accumulate as siblings under {partitionRoot}/_Activity/*, and the Code node's LastExecutedAt field is stamped on each run. A Run button rendered on Code views (visible when the caller has Permission.Execute) wires this exact request.
What a cell may claim about its own output — the currency rule
The stamp writes four fields together — LastExecutedAt, LastExecutedBy, LastActivityPath and LastExecutedCodeHash — and the last of them is the proof the other three lack: the CodeFingerprint of what that run actually submitted. A view re-computes the fingerprint from the node's current Code/Language and compares, which is how a cell knows the output pane below it belongs to source the reader has since edited.
🚨 The stamp is a write, and a write can fail. It is dispatched fire-and-forget after the run has already been acknowledged (the Run click must never be silent), so a failure reaches a log and nothing else. That leaves a node that ran while recording only part of what it ran — and a boolean "is it stale?" has nowhere honest to put that. Answering not stale asserts a currency nothing substantiates: a wrong claim, not a missing one.
So the verdict is not a boolean. CodeConfiguration.OutputCurrency() (MeshWeaver.Mesh.Contract) is the one rule every surface must use, and it fails closed:
| State | When | What the cell may show |
|---|---|---|
NeverRun |
no run recorded at all — no fingerprint, no timestamp, no activity path, no runner | nothing; a cell nobody has run has no output to be wrong about |
Current |
a run is recorded and its fingerprint reproduces from the code on screen | "up to date" — the only state that may say so |
Stale |
a run is recorded, its fingerprint was recorded, and the code has moved since | "code changed — re-run" |
Unverified |
a run is recorded but its fingerprint is not | "output unverified — re-run to be sure" |
Unverified is the fail-closed state, and it is deliberately neither of the two it sits between. It is not Current, because nothing proves the output belongs to the code above it. It is not Stale either: every node last executed by a build that predates the fingerprint field would light up amber at once, which trains readers to ignore the indicator — the failure mode the fingerprint's own normalization rules exist to avoid.
"A run is recorded" is deliberately generous — any of the four stamped fields present, not LastExecutedAt specifically — so a stamp that landed only in part cannot fall back into NeverRun and go silent on a cell that ran.
The fingerprint is tested first, before the other three. It is both evidence of a run and the only field that can decide currency, so a node carrying the hash and nothing else is fully determinable; checking the run markers first would answer NeverRun there and silence a verdict we can actually substantiate — the same fail-open shape, mirrored.
The residual, stated rather than hidden: a cell whose very first run failed to stamp anything at all records nothing, and is therefore indistinguishable from a cell nobody has run. No rule over the node's content can recover that. What happens instead is below.
When the stamp does not land
The stamp is one write to one node. It lands whole or not at all — there is no partial outcome to read a warning out of — so a cell whose stamp failed reads exactly as NeverRun. Recovering that on the cell is not possible from the dispatching hub, and the reasons are worth writing down because all three candidate shapes look plausible until you follow them:
| Candidate | Why not |
|---|---|
Put a LastStampFailedAt marker on the cell |
It records the failure of a node write by performing another node write to the same node through the same handle. In every failure mode that actually occurs — the workspace not ready, the partition write refused, the content unreadable — the second write fails for the reason the first one did. Recovery in the same failure domain is not recovery. |
| Append the failure to the run's Activity | The Activity is a different failure domain, and it is where FailActivity already writes. But for the in-process C# path the activity's transcript has a single writer that re-asserts it wholesale: ActivityLogLogger.PublishSnapshotLocked writes Messages, MessageCount, MaxSeverity, Status and End from its own private list on every flush and on Complete. An append issued from the dispatcher — which runs immediately after the submit, i.e. before the kernel has published anything — is overwritten by the next snapshot. Worse if it survived: an Error-level line rolls MaxSeverity up, and a terminal write that folds severity would report a successful run as failed. |
Have ExecuteScriptResponse carry the verdict |
The response is posted before the stamp resolves. Waiting for it undoes MeshWeaver.Plugins#1266, whose whole point is that a Run click is acknowledged immediately and never silently. A stamp write that stalls would then hold the acknowledgement of a run that is already executing. |
So the failure is reported, not recovered: CodeNodeType.ReportStampNotRecorded logs one line at Error, under event id 3249, naming the cell, the activity and the fact that the run itself succeeded.
Two things make that an honest answer rather than a shrug.
The run's durable record is not lost. The Activity node was created before the dispatch and it carries the originating cell on ActivityLog.HubPath. A failed stamp loses the cell's pointer to its run, not the run. A listing over _Activity can still find it.
Every way the stamp can fail now reaches that one line. It previously had two log calls and a third path with none: the update lambda tested curr.Content is CodeConfiguration, so content arriving as untyped JSON — the polymorphic converter degrading an unresolvable $type, the as-written JsonObject DOM, a same-named record from another collectible assembly — simply did not match, the write short-circuited as a no-op, and the stamp vanished with no exception and no log at all. The stamp now goes through the typed Update<CodeConfiguration> overload, which refuses to write unreadable content and faults with the runtime type and a JSON excerpt instead of silently writing a default-valued record over the cell.
Error rather than Warning is deliberate and costs nothing extra: both levels ship to Loki and the path is exceptional, so the only thing that changes is that it trips error-rate alerting. That is the right classification — nothing retries the write, the loss is permanent, and the visible consequence is a cell that tells a human it has never run. "Degraded but self-correcting" is what Warning claims, and none of it holds here.
Recovering the run for the READER — follow the edge, do not write another marker
The three candidates above all fail for the same reason: they are writes. What the reader needs is a read, and the edge to read along already exists. CodeRunHistory.ResolveOutputCurrency (MeshWeaver.Mesh.Contract, beside the currency rule it extends) walks it:
// The verdict a cell can substantiate when its own stamp cannot supply one.
// Reactive like every other read — one verdict, then completion.
code.ResolveOutputCurrency(cellPath, viewerHome, meshService)
.Subscribe(verdict => { /* NeverRun · Unverified · Stale · Current */ });
| Step | What happens | Cost |
|---|---|---|
| 1 | OutputCurrency() — the stamp. Anything but NeverRun is returned unchanged. |
no query |
| 2 | Only on NeverRun: one listing for an Activity naming this cell on HubPath. A hit ⇒ Unverified. |
one row |
| 3 | No hit ⇒ NeverRun, exactly as today. |
— |
Three properties are what make this the right shape rather than merely a working one.
It is a listing by predicate, not a point read. Query is eventually consistent, so it may never decide what a specific known node contains — that stays GetMeshNodeStream's job. Here a stale negative is harmless by construction: the worst outcome is the answer the cell already gives today.
A recovered run is Unverified, never Current. The Activity records that the cell ran, never what it ran — no fingerprint travels with it. That is the same evidence a stamp which landed without its hash leaves, so it gets the same fail-closed verdict, for the same reason.
It runs only where it can change the outcome. A notebook of cells that ran normally costs zero queries, because step 1 answers first. This is the whole reason the lookup hangs off NeverRun rather than being a general "find this cell's runs" call every view makes.
Where it looks. The run lands under {activityParent}/_Activity, and CodeNodeType.ResolveActivityParent picks that parent in three layers: the Code node's own CodeConfiguration.ActivityParentPath, then the partition's PartitionDefinition.DefaultActivityParentPath, then the partition root — with the {viewer} sentinel at either configured layer expanding to the calling user's home. CodeRunHistory.ActivityNamespaces reconstructs the layers it can see from the cell alone — the node's own ActivityParentPath with {viewer} expanded, the partition root, and the reading viewer's home — deduplicated into a single union query, usually one namespace. A partition-level DefaultActivityParentPath pointing somewhere else is not covered: that lookup is a live query on the partition registry, which MeshWeaver.Mesh.Contract sits below. Where it applies the listing finds nothing and the verdict falls back to NeverRun — the answer the cell gives today, so the gap costs nothing that was already working. A caller that has already resolved the parent (the dispatcher has, via CodeNodeType.ResolveActivityParent) skips the derivation and calls CodeRunHistory.RunsQuery with the namespace it resolved.
🚨 Two cross-assembly agreements hold this together, and neither is visible to a compiler. The lookup filters on nodeType:Activity and expands the {viewer} sentinel — both declared in assemblies it sits below (GraphNodeTypeNames.Activity, CodeNodeType.ResolveActivityParent). A rename on either side would leave the query looking for something nothing writes: zero rows, every recovered cell silently back to NeverRun, no compiler error anywhere. CodeRunHistoryTest pins both — the node-type name by equality, the sentinel by driving the dispatcher's own resolver and requiring the answer the lookup assumes.
What remains uncovered. The lookup answers whether the cell ran, not what it produced: it deliberately does not re-point the output pane at the recovered activity. And an operator still gets the Error under event id 3249 — the stamp failing is a real fault, and a reader who can no longer see the consequence is not a reason to stop reporting it.
2. From application code — SubmitCodeRequest directly
For one-off submissions — interactive markdown cells, REPL-style flows — where you already own an Activity hub address:
hub.Post(
new SubmitCodeRequest(@"Log.LogInformation(""hello""); 1 + 1") { Id = "cell-7" },
o => o.WithTarget(activityAddress));
Progress flows into the activity hub's ActivityLog content the same way as for ExecuteScriptRequest.
3. From an MCP agent — execute_script
Agents call the same path as ExecuteScriptRequest but through the MCP tool surface. Activity creation and activity-log streaming behave identically. Agents authoring a new script must follow the same progress conventions below so a human watching the run sees it unfold in real time.
// Agent-side tool call
{
"tool": "execute_script",
"args": { "path": "rbuergi/daily-rollup" }
}
The tool returns the dispatch verdict, not the run's result — it comes back as soon as the run is accepted, and the script keeps going:
// Accepted. `activityPath` is what the OWNING hub created — never a path the caller
// reconstructed, so it is correct even when ActivityParentPath / the partition default /
// the {viewer} sentinel route the activity somewhere other than the Code node's partition.
{ "status": "Dispatched", "path": "rbuergi/daily-rollup",
"submissionId": "…", "activityPath": "rbuergi/_Activity/…" }
// Refused or faulted. There is NO activityPath — nothing was created, and nothing is pending.
{ "status": "Error", "path": "rbuergi/daily-rollup",
"message": "Not executable: 'rbuergi/daily-rollup' has CodeConfiguration.IsExecutable = false." }
Dispatched therefore means "an Activity node exists at activityPath", and every way the
dispatch can fail — the node is missing or unreadable, it is not executable, the Activity could not
be created, the request never routed, the acknowledgement never came — arrives as status: "Error"
carrying the reason (and the exception type where there was one). The owning hub logs the same
verdict at Warning/Error on the MeshWeaver.Graph.CodeNodeType channel, so an operator can find
a failed dispatch in pod stdout without the caller's transcript. Poll get @{activityPath} for
progress and the terminal status; a script's own exception surfaces there, not in the dispatch
verdict.
Creating typed / restricted-partition nodes — the execute_script escape hatch
The MCP create and patch tools validate a node's content.$type against the hub they run on. Some content types are registered only on a dedicated per-type hub (via WithContentType<T>()), not on the general MCP hub — so raw create rejects them. Invitation is the canonical example:
Content … carries the polymorphic discriminator '$type': 'Invitation',
which is not a registered content type for the built-in NodeType 'Invitation'
The same wall guards writes into a restricted partition — e.g. the Admin partition, which ordinary identities can't write to directly (see Access Control).
The way through is to run the write inside the mesh, through the canonical service, via execute_script. The script's Mesh global resolves any registered service and the kernel references every loaded assembly — so you call exactly what the GUI calls. The write then routes to the owning hub (which does know the type), and the service's ImpersonateAsSystem() scope satisfies the partition write-guard.
Recipe — three MCP calls:
createa throwaway executableCodenode.CodeConfigurationis a registered content type, socreateaccepts it:
{
"id": "InviteUsers", "namespace": "rbuergi", "name": "Invite users (delete me)",
"nodeType": "Code",
"content": { "$type": "CodeConfiguration", "language": "csharp", "isExecutable": true,
"code": "/* the script in step 2 */" }
}
- The script body resolves the canonical service and Subscribes (the service impersonates System internally to reach the Admin partition):
using System.Reactive.Linq;
using MeshWeaver.Messaging; // AccessService, ReactiveCompletion.ObserveCompletion
using MeshWeaver.Mesh.Services; // IMeshService
using Microsoft.Extensions.DependencyInjection;
using Memex.Portal.Shared.Authentication; // InvitationService
var sp = Mesh.ServiceProvider;
var svc = new InvitationService(
sp.GetRequiredService<IMeshService>(),
sp.GetRequiredService<AccessService>());
foreach (var (email, name) in new[]
{
("ada.lovelace@example.com", "Ada Lovelace"),
("grace.hopper@example.com", "Grace Hopper"),
})
{
// 🚨 ObserveCompletion, never Rx's own observable-to-Task bridge (maintainer,
// 2026-08-30: "no ToTask ever") — and never a bare `await …FirstAsync()` either, because
// Rx's awaiter resumes the rest of this script INLINE on whichever hub thread signalled.
// FirstAsync (not Take(1)): the next line dereferences node.Path, so an empty completion
// must keep FAULTING rather than settling with null.
var node = await svc.CreateInvitation(email, invitedBy: "rbuergi", note: name)
.FirstAsync()
.ObserveCompletion(
ex => Log.LogWarning(ex, "CreateInvitation faulted AFTER the wait settled"),
Ct);
Log.LogInformation("Created {Path}", node.Path);
}
execute_scriptthe node, confirmstatus: Succeededon the returned activity, thendeletethe throwaway node.
The effect is identical to the equivalent GUI action — including any node-driven follow-up (here, the invitation email that InvitationEmailSender sends for every Pending invitation it hasn't emailed yet).
This is a break-glass / admin path, not an application pattern. Application code, agents, and the GUI write through the typed service or
stream.Updatedirectly — never raw MCPcreatefor these types. Reach forexecute_scriptonly for one-off operational writes the MCP surface legitimately can't express.
Writing progress in scripts
Every script receives four globals (MeshScriptGlobals, MeshWeaver.Kernel.Hub):
| Global | Type | Purpose |
|---|---|---|
Mesh |
IMessageHub |
Full mesh access — post messages, subscribe to streams, mutate nodes. |
Log |
ILogger |
Each call appends to ActivityLog.Messages and flushes a snapshot to all subscribers within milliseconds. |
Ct |
CancellationToken |
Rebound per submission. Pass it to every cancellable async API so user-initiated cancellation actually interrupts in-flight work. |
Inputs |
IReadOnlyDictionary<string, JsonElement> |
Caller-supplied payload, forwarded from ExecuteScriptRequest.Inputs via SubmitCodeRequest.Inputs. Empty for the plain REPL / launch-button case. Read with Inputs["title"].GetString() or Inputs["options"].Deserialize<ExportOptions>(). |
Inputs is what makes "operation as a script" work without a side-channel node: the form or caller patches the inputs onto the request and the script reads them as typed JSON. Values are carried as JsonElement so any JSON shape survives serialization across hub boundaries without a type-registry entry per shape.
Always pass
Ctto async calls.Task.Delay(ms, Ct),HttpClient.GetAsync(url, Ct),.FirstAsync(predicate).ObserveCompletion(report, Ct)— every cancellable API should receive it. WithoutCt, clicking Cancel in the Activity Control Plane sends the signal but the script can't act on it until the current await returns.
🚨 A script body is one of the few places an
awaitis legitimate — the bridge it uses is not free choice.ObserveCompletion(inMeshWeaver.Messaging, imported by default) is the only sanctioned way to take a value out of an observable here..ToTask(...)is forbidden repo-wide as of the 2026-08-30 ruling ("no ToTask ever"), andawait someObservable/await source.FirstAsync()is the same defect wearing a shorter spelling: Rx's awaiter is anAsyncSubject<T>that completes its continuation from insideOnCompleted, so the remainder of your script runs on the mesh thread that signalled — inside the portal, holding a hub's action block.ObserveCompletioncompletes through aTaskCompletionSourcecreated withRunContinuationsAsynchronously, so the signalling thread is released immediately, and itsreportLateFaultarm stays attached for a fault that lands after the wait settled. Never pass an empty lambda there.
Reactive waiting
Don't Thread.Sleep and don't Task.Delay(ms) without the cancellation token. For waiting on external state, the right shape is a reactive subscription on the workspace — it's both cancellable and gives you a natural place to log progress:
// Wait for a downstream node to flip to a target status — cancellable mid-flight.
Log.LogInformation("Waiting for downstream job to finish…");
await Mesh.GetWorkspace()
.GetMeshNodeStream("rbuergi/downstream-job")
.Where(n => (n?.Content as JobContent)?.Status == JobStatus.Succeeded)
.FirstAsync() // ← not .Take(1): an empty completion must FAULT,
// or ObserveCompletion settles with null and the
// script proceeds as though the wait had succeeded
.ObserveCompletion(
ex => Log.LogWarning(ex, "downstream watch faulted AFTER the wait settled"),
Ct); // ← Ct cancels the WAIT, so Cancel is honoured
Log.LogInformation("Downstream finished — proceeding");
Don't loop-poll; subscribe and let the workspace push you the next emission.
For longer waits with periodic heartbeats, combine Observable.Interval with a linked cancellation source:
Log.LogInformation("Crunching…");
using var cts = System.Threading.CancellationTokenSource
.CreateLinkedTokenSource(Ct);
var heartbeat = System.Reactive.Linq.Observable
.Interval(TimeSpan.FromSeconds(5))
.Subscribe(t => Log.LogInformation($"Still working — {t * 5}s elapsed"));
try
{
await DoLongWork(cts.Token);
}
finally
{
heartbeat.Dispose();
}
Log calls — what they emit
Every Log.LogInformation(...) snapshot lands on the activity log within milliseconds of the call. Console output is captured too — each completed line becomes an Information-level entry on the same activity log.
Log.LogInformation("Starting import...");
Log.LogWarning("Skipping malformed row {Row}", row);
Log.LogError(ex, "Failed to write {Path}", path);
// Console output is captured — each completed line lands as
// an Information-level entry on the same activity log.
Console.WriteLine("Wrote 42 rows");
Rules of thumb for human-readable progress
- One log call per logical step. Don't log every iteration of a tight loop — bundle into a single line per step. Fewer, more meaningful entries beat a flood.
- Use structured templates (
"Wrote {Count} rows"not"Wrote " + count + " rows"). Better diagnostics, no string allocation in the hot path. - Log at the start of long phases so a watcher knows what's happening during the silent stretch before the next checkpoint.
- Log the result, not just "done" — include counts, paths, durations. The activity log is the primary post-mortem surface.
Rules for agent-authored scripts
When an agent (Claude, Copilot, etc.) writes a script for an MCP user to run, the agent is responsible for emitting useful progress. Silent scripts are unobservable scripts.
- Begin with
Log.LogInformation("...")describing what the script is about to do. - Emit progress at every coarse-grained step — per file processed, per phase entered.
- On failure paths, prefer
Log.LogError(ex, "...")over throwing. The activity will still flip toFailed(terminal log levels are aggregated into status), and the message survives so the user can diagnose what went wrong. - End with
Log.LogInformation("Completed: {Summary}", summary)so the user knows the run is finished and isn't still in flight.
Observing progress
Subscribers use the canonical CQRS read pattern: subscribe to the activity node's shared per-path handle and observe the ActivityLog content updating live.
🚨 Read a node by path with
GetMeshNodeStream(path), neverGetRemoteStream<MeshNode, MeshNodeReference>(...).GetRemoteStreamis framework plumbing; using it for a node by path opens a second upstream handle instead of joining the process-wideIMeshNodeStreamCacheentry — so writes made through the shared handle are invisible to your subscription. (Several XML doc comments inMeshWeaver.Kerneland onExecuteScriptResponse.ActivityLogstill recommendGetRemoteStream; they are stale — follow this page.)
var workspace = hub.GetWorkspace();
var log = workspace.GetMeshNodeStream(activityPath)
.Select(node => node?.Content as ActivityLog)
.Where(l => l is not null)
.Select(l => l!);
// Wait for the run to finish and get the terminal snapshot — reactive, no await.
log.Where(l => l.Status != ActivityStatus.Running)
.Take(1)
.Timeout(TimeSpan.FromMinutes(2))
.Subscribe(
final =>
{
foreach (var msg in final.Messages)
logger.LogInformation("[{Level}] {Message}", msg.LogLevel, msg.Message);
},
ex => logger.LogWarning(ex, "Activity stream failed for {Path}", activityPath));
For live display — a streaming UI stripe or detail view — don't filter by terminal status, and don't .Take(1) (it would freeze the binding). Project the field you care about and let the subscription tick on every snapshot:
var liveMessageCount = log
.Select(l => l.Messages.Count)
.DistinctUntilChanged();
Never query for the activity log with
IMeshService.QueryAsync("path:..."). The query path is eventually consistent and will lag behind the workspace stream — you may miss intermediate snapshots or read a staleStatus.GetMeshNodeStream(path)bypasses the index and observes the owning hub's workspace directly. See CqrsAndContentAccess.md.
How progress stays timely — the architecture
The Activity hub does not run the script itself. When a SubmitCodeRequest arrives, a thin KernelContainer forwarder spins up a hosted child hub ({activity}/_KernelExec) and delegates execution there. Scripts run inside the executor's action block; the Activity hub's action block stays free to accept new submissions and process the DataChangeRequests the script's Log writer pushes back.
Because the executor's address is internal and never exposed to clients, every external caller and every subscriber sees the kernel as a single addressable surface — the Activity hub. The forwarding is an implementation detail.
Live demo — script globals available in cells
The same Mesh, Log, Ct, and Inputs globals available inside a mesh script are also available in interactive markdown cells. This cell renders them through Controls.DataGrid — the standard way to render tabular data. Never hand-build an HTML string ($"<table>…", StringBuilder, Controls.Html(markup)) for structured data; Controls.Html is only for genuinely pre-rendered rich text.
record ScriptGlobal(string Name, string TypeName, string Purpose);
var globals = new[]
{
new ScriptGlobal("Mesh", "IMessageHub",
"Full mesh access — post messages, subscribe to streams, mutate nodes."),
new ScriptGlobal("Log", "ILogger",
"Appends to ActivityLog.Messages; each call flushes a snapshot to all subscribers."),
new ScriptGlobal("Ct", "CancellationToken",
"Rebound per submission. Pass to every cancellable async API."),
new ScriptGlobal("Inputs", "IReadOnlyDictionary<string, JsonElement>",
"Caller-supplied payload from ExecuteScriptRequest.Inputs. Empty for a plain REPL run."),
};
Controls.DataGrid(globals)
.WithColumn(new PropertyColumnControl<string> { Property = "name" }.WithTitle("Global"))
.WithColumn(new PropertyColumnControl<string> { Property = "typeName" }.WithTitle("Type"))
.WithColumn(new PropertyColumnControl<string> { Property = "purpose" }.WithTitle("Purpose"))
Common pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
await inside a click handler wrapping ExecuteScriptRequest |
Click actions must be synchronous. | Use hub.Post(...) (fire-and-forget) or hub.Observe(...).Subscribe(...). See AsynchronousCalls.md. |
Subscribing only to SubmitCodeResponse |
That's the completion ack — it carries no progress. | Subscribe to the activity log via GetMeshNodeStream(activityPath). |
Polling IMeshService.QueryAsync for activity status |
Eventually consistent, will lag. | Use GetMeshNodeStream(activityPath) — it observes the owning hub's workspace directly. |
GetRemoteStream<MeshNode, MeshNodeReference>(...) to read an activity by path |
Opens a second upstream handle instead of the shared cache entry — writes through the shared handle are invisible to it. | GetMeshNodeStream(activityPath). |
Console.WriteLine from heavy parallel loops |
Every line becomes an activity-log message; flooding the log overwhelms subscribers and may DoS the workspace. | Aggregate before logging — one line per step, not per iteration. |
| Long synchronous CPU loops with no log calls | No Log call → no snapshot → subscribers see nothing. |
Add a heartbeat log if a phase runs longer than ~1 s. |
Raw MCP create rejects a node's $type ("not a registered content type") |
The type is registered only on a dedicated hub, or the target sits in a restricted partition. | Run the write via execute_script through the canonical service — see Creating typed / restricted-partition nodes above. |