Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@ Never use `IMeshStorage` or `IMeshCatalog` directly β€” internal infrastructure

| Operation | API |
|---|---|
| Read (query) | `IMeshService.QueryAsync(...)` |
| Read (query) | `IMeshService.Query<T>(request)` β€” reactive. 🚨 There is **no** `QueryAsync` on the production interface; it survives only as a test-only bridge in `MeshWeaver.Fixture`. One-shot snapshot = `.Where(c => c.ChangeType == QueryChangeType.Initial).Select(c => c.Items).FirstAsync()` |
| Read (single node) | `workspace.GetMeshNodeStream(path)` |
| Create/Delete | `meshService.CreateNode(node).Subscribe(...)` / `meshService.DeleteNode(path).Subscribe(...)` |
| Update | `workspace.GetMeshNodeStream(path).Update(current => current with { … })` |
Expand Down
3 changes: 3 additions & 0 deletions clients/react/src/i18n/strings.de.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,9 @@
"ui.sendDocument.attachedNote": "Anbei finden Sie das Dokument.",
"ui.sendDocument.fromLabel": "Von",
"ui.sendDocument.connect": "Microsoft 365 verbinden",
"ui.sendDocument.connectFirst": "Ihr Microsoft-365-Postfach ist noch nicht verbunden. Verbinden Sie es jetzt, um von Ihrer eigenen Adresse zu senden β€” bereits Eingegebenes bleibt erhalten.",
"ui.sendDocument.signInTitle": "Zum Teilen per E-Mail anmelden",
"ui.sendDocument.signInBody": "Das Teilen eines Dokuments per E-Mail erfordert ein angemeldetes Konto, damit Antworten Sie erreichen und Ihr Entwurf privat gespeichert wird.",
"ui.sendDocument.fromSharedTitle": "Aus dem gemeinsamen Postfach senden?",
"ui.sendDocument.fromSharedBody": "Ihr Microsoft-365-Postfach ist nicht verbunden, daher wΓΌrde dies aus dem gemeinsamen Portal-Postfach statt von Ihrer eigenen Adresse gesendet.",
"ui.sendDocument.sendFromShared": "Aus dem gemeinsamen Postfach senden",
Expand Down
3 changes: 3 additions & 0 deletions clients/react/src/i18n/strings.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,9 @@
"ui.sendDocument.attachedNote": "Please find the attached document.",
"ui.sendDocument.fromLabel": "From",
"ui.sendDocument.connect": "Connect Microsoft 365",
"ui.sendDocument.connectFirst": "Your Microsoft 365 mailbox is not connected yet. Connect it now to send from your own address β€” anything you have already typed is kept.",
"ui.sendDocument.signInTitle": "Sign in to share by email",
"ui.sendDocument.signInBody": "Sharing a document by email needs a signed-in account, so replies reach you and your draft is kept privately.",
"ui.sendDocument.fromSharedTitle": "Send from the shared mailbox?",
"ui.sendDocument.fromSharedBody": "Your Microsoft 365 mailbox is not connected, so this would go out from the shared portal mailbox rather than your own address.",
"ui.sendDocument.sendFromShared": "Send from the shared mailbox",
Expand Down
17 changes: 16 additions & 1 deletion content/ai/Skill/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,22 @@ stream.Subscribe(async node => { await mesh.UpdateNode(node with { ... }); });
var node = await Mesh.ReadNodeAsync(path); // ← doesn't exist; don't invent it
```

**Rule of thumb for Scripts:** read known paths one-shot via `Mesh.GetMeshNode(path, timeout).ToTask()`; wait for state changes via `workspace.GetMeshNodeStream(path).Where(predicate).Take(1).Timeout(...)`. Use `mesh.QueryAsync(...)` / `mesh.Query(...)` only for searching / listing / counting (sets, not specific node content). Reach for `QueryAsync(path:X)` and you've written a stale-read bug.
**Rule of thumb for Scripts:** read known paths one-shot via `Mesh.GetMeshNode(path, timeout).ToTask()`; wait for state changes via `workspace.GetMeshNodeStream(path).Where(predicate).Take(1).Timeout(...)`. Use `mesh.Query<T>(...)` only for searching / listing / counting (sets, not specific node content). Reach for a query on `path:X` and you've written a stale-read bug.

> 🚨 **`QueryAsync` does not exist on the production `IMeshService`.** It survives only as a
> test-only bridge in `MeshWeaver.Fixture`, so a script calling it compiles in a test process (which
> loads that assembly) and fails at runtime in the portal with `CS1061`. That is exactly how every
> PDF/DOCX/email export died in production on 2026-08-12. The script reference set now excludes test
> scaffolding, so this fails identically everywhere β€” but write the reactive shape:
>
> ```csharp
> var items = await meshService
> .Query<MeshNode>(MeshQueryRequest.FromQuery("path:" + p + " scope:descendants"))
> .Where(c => c.ChangeType == QueryChangeType.Initial) // filter on SHAPE, never a count
> .Select(c => c.Items)
> .FirstAsync()
> .ToTask(Ct);
> ```

# Decision Rule: NodeType vs Markdown

Expand Down
38 changes: 36 additions & 2 deletions src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@
@{ var selInitial = GetInitial(_selectedNode); }
@RenderAvatar(_selectedNode, selInitial)
<div class="mesh-node-picker-info">
<div class="mesh-node-picker-name" title="@_selectedNode.Path">@(_selectedNode.Name ?? _selectedNode.Id)</div>
<div class="mesh-node-picker-name" title="@_selectedNode.Path">
@(_selectedNode.Name ?? _selectedNode.Id)
@{ var selEmail = GetEmail(_selectedNode); }
@if (!string.IsNullOrEmpty(selEmail))
{
<span class="mesh-node-picker-email">&lt;@selEmail&gt;</span>
}
</div>
</div>
@if (!string.IsNullOrEmpty(_selectedNode.Category))
{
Expand Down Expand Up @@ -81,7 +88,14 @@
@onclick="() => SelectNode(node)">
@RenderAvatar(node, initial)
<div class="mesh-node-picker-item-info">
<div class="mesh-node-picker-item-name" title="@node.Path">@(node.Name ?? node.Id)</div>
<div class="mesh-node-picker-item-name" title="@node.Path">
@(node.Name ?? node.Id)
@{ var itemEmail = GetEmail(node); }
@if (!string.IsNullOrEmpty(itemEmail))
{
<span class="mesh-node-picker-email">&lt;@itemEmail&gt;</span>
}
</div>
</div>
@if (!string.IsNullOrEmpty(node.Category))
{
Expand All @@ -101,6 +115,26 @@
return !string.IsNullOrEmpty(name) ? name[0].ToString().ToUpper() : "?";
}

/// <summary>
/// The node's email address, when it has one. Two similarly-named contacts are otherwise
/// indistinguishable in the dropdown, and someone picking a recipient for a mail wants to see
/// the address the mail will actually go to β€” so a suggestion reads "First Last &lt;a@b.com&gt;".
/// Probed off the raw content the way the avatar is: the picker's query carries no `select:`,
/// so content comes back whole.
/// </summary>
private static string? GetEmail(MeshNode node)
{
if (node.Content is System.Text.Json.JsonElement json
&& json.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (json.TryGetProperty("email", out var e) && e.ValueKind == System.Text.Json.JsonValueKind.String)
return e.GetString();
if (json.TryGetProperty("Email", out var E) && E.ValueKind == System.Text.Json.JsonValueKind.String)
return E.GetString();
}
return null;
}

private string? GetImageUrl(MeshNode node)
{
if (node.Content is System.Text.Json.JsonElement json)
Expand Down
9 changes: 9 additions & 0 deletions src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@
color: var(--neutral-foreground-hint, #666);
}

/* The address beside a contact's name β€” "First Last <a@b.com>". Subordinate to the name (lighter,
regular weight) so the row still scans by name, but present so two similarly-named people can be
told apart and the sender can see exactly where the mail is going. */
.mesh-node-picker-email {
font-weight: 400;
color: var(--neutral-foreground-hint, #666);
margin-left: 4px;
}

/* Open-upward dropdown β€” for bottom-anchored fields (e.g. the chat composer). Bound the height to
the space available ABOVE the field so it doesn't grow up over the panel header (the chat-composer
overlap). Whichever is smaller wins: the 300px cap or ~half the viewport. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
Name: A half-written email is no longer lost when you connect
Category: Fix
Description: Filling in a share-by-email form and then connecting Microsoft 365 emptied the form. Your draft is now kept, and sending works again.
Icon: Sparkle
Order: -20260813
---

# A half-written email is no longer lost when you connect

Sharing a document by email asked for the recipient, the subject and a message β€” and then, if your
Microsoft 365 mailbox was not connected yet, offered to connect it. Coming back from that sign-in,
the form was empty. Everything typed was gone, with no way to get it back.

Connecting has to leave the portal for Microsoft's sign-in page, and until now the half-written mail
only existed in the page you left. Now it is kept for you: what you type is saved as you go, and the
form fills itself back in when you return. Closing the tab by accident no longer costs you the
message either.

Two things changed alongside it, both about the same moment. If your mailbox is not connected, the
dialog says so **before** you start writing rather than after you press Send β€” so you are not asked
to authenticate at the point where it costs you the most. And the recipient list now shows each
person's email address next to their name, so two colleagues with similar names can be told apart
and you can see exactly where the mail is going.

Your draft is private to you: it is stored in your own space, so colleagues who can read the
document cannot read what you were writing about it. It is cleared once the mail is sent, when you
cancel, and after a week untouched β€” an old recipient will not resurface weeks later.

**Sending itself was also broken and is fixed.** Exporting or emailing a document failed outright
with a script error: the built-in export templates called a function that no longer exists outside
of our test suite. These templates are compiled while the portal runs, so no build could catch it β€”
they are now checked against the real portal on every change, which is what let this reach you.
39 changes: 39 additions & 0 deletions src/MeshWeaver.Kernel.Hub/KernelScriptReferences.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,48 @@ internal static class KernelScriptReferences

private static ImmutableArray<PortableExecutableReference> CreateSharedSnapshot()
=> AppDomain.CurrentDomain.GetAssemblies()
.Where(IsProductionReferenceable)
.Select(TryGetOrCreate)
.Where(r => r is not null)
.Select(r => r!)
.Distinct()
.ToImmutableArray();

/// <summary>
/// 🚨 The script reference set must be the set a PRODUCTION portal would have β€”
/// never "whatever this process happens to have loaded".
///
/// <para><b>The defect this closes (2026-08-12, exports dead in prod):</b> the
/// reference set is built from <see cref="AppDomain.CurrentDomain"/>, and a TEST
/// process always has <c>MeshWeaver.Fixture</c> loaded (<c>MonolithMeshTestBase</c>
/// derives from <c>Fixture.TestBase</c>). That assembly declares, in namespace
/// <c>MeshWeaver.Mesh</c> β€” which <see cref="MeshScriptEnvironment.Imports"/> puts
/// in scope for every script β€” the test-only <c>IMeshService.QueryAsync&lt;T&gt;</c>
/// bridge whose own doc comment says <i>"Production code MUST NOT use these"</i>.
/// So the three export <c>.csx</c> templates bound to it, compiled green in ELEVEN
/// tests that genuinely execute them, and threw CS1061 on the first real export in
/// production. Test compilation was strictly MORE permissive than production, which
/// makes those tests unable to gate the thing they exist to gate.</para>
///
/// <para>Excluding test scaffolding here makes script compilation in a test process
/// IDENTICAL to production, so every existing script test becomes a real gate for
/// dead-API references. This enforces a contract the Fixture already states about
/// itself; it is not a heuristic about what scripts "should" use.</para>
///
/// <para>Note this bounds only the <b>script</b> reference set. Test assemblies keep
/// their ordinary project references, so test C# still calls the Fixture bridges
/// normally β€” it is only script text that loses the production-shadowing surface.</para>
/// </summary>
private static bool IsProductionReferenceable(Assembly asm)
{
var name = asm.GetName().Name;
if (string.IsNullOrEmpty(name)) return true;
return !IsTestScaffolding(name);
}

private static bool IsTestScaffolding(string assemblyName)
=> MeshScriptEnvironment.IsTestScaffolding(assemblyName);

/// <summary>
/// The shared reference for <paramref name="asm"/>, or null when the assembly
/// cannot be referenced (dynamic, byte-loaded, or its file was deleted β€”
Expand Down Expand Up @@ -131,6 +167,9 @@ public static ImmutableArray<MetadataReference> GetReferences(IEnumerable<Assemb
}
foreach (var asm in sessionAssemblies)
{
// Same production-parity gate as the snapshot β€” a DI-contributed module
// assembly in a test process must not widen the script surface either.
if (!IsProductionReferenceable(asm)) continue;
var reference = TryGetOrCreate(asm);
if (reference is not null && seen.Add(reference))
result.Add(reference);
Expand Down
20 changes: 20 additions & 0 deletions src/MeshWeaver.Kernel.Hub/MeshScriptEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,24 @@ public static IReadOnlyCollection<Assembly> SessionAssemblies(IServiceProvider s
/// </summary>
public static ImmutableArray<MetadataReference> References(IServiceProvider serviceProvider)
=> KernelScriptReferences.GetReferences(SessionAssemblies(serviceProvider));

/// <summary>
/// Whether an assembly is TEST SCAFFOLDING and must therefore be kept out of the script
/// reference set β€” identified by the naming convention this repo uses: the shared fixture, the
/// per-host test bases, and the test assemblies themselves. None exists in a deployed portal.
///
/// <para>🚨 This is what keeps script compilation in a test IDENTICAL to production. The
/// reference set is built from the assemblies loaded in the process, and a test process always
/// has <c>MeshWeaver.Fixture</c> loaded β€” whose test-only <c>IMeshService.QueryAsync</c> lives
/// in the very namespace scripts import. Without this filter, tests compile scripts against APIs
/// production does not have: the export templates bound that method, passed eleven tests that
/// really executed them, and threw CS1061 on the first export a user attempted.</para>
/// </summary>
/// <param name="assemblyName">The simple assembly name (no extension).</param>
/// <returns>True when the assembly is test-only.</returns>
public static bool IsTestScaffolding(string assemblyName)
=> assemblyName.Equals("MeshWeaver.Fixture", StringComparison.Ordinal)
|| assemblyName.EndsWith(".TestBase", StringComparison.Ordinal)
|| assemblyName.EndsWith(".Test", StringComparison.Ordinal)
|| assemblyName.EndsWith(".Tests", StringComparison.Ordinal);
}
Loading
Loading