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
137 changes: 105 additions & 32 deletions src/MeshWeaver.Compiler.Pipeline/MeshNodeCompilationService.cs

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/Localization.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,45 @@ missing from both), and no target-typed `Messages = [new(…)]`, which construct
naming no type and is therefore invisible to any textual census — three such sites were missing from
the issue's own count for exactly that reason.

#### What is migrated, and what is deliberately not

The write sites came over in two passes — #3236 landed the seam plus 37 of them, #3281 the rest of
what was migratable — and the allow file is now **18 sites, all of them intended to stay**. What
changed in the second pass is worth knowing because it is the shape any future migration takes:

- **A helper whose whole input is a `string message` cannot key anything**, so the key is threaded
through it or the caller stops using it. `ActivityRunner` grew a `RunActivity(…, LogMessage title, …)`
overload and an `ActivityContext.Log(LogMessage)`, which is what let all eight GitHub operations
key their titles and progress lines. `MeshNodeCompilationService`'s `AppendInfo`/`AppendWarning`/
`AppendError` went the other way: the key became a **required** parameter, and the one caller that
genuinely cannot supply one (Roslyn's own diagnostics) calls a separately-named
`AppendVerbatimWarning`. `NodeTypeCompilationActivity.AppendLog` needed no change at all — its
callers moved to the batched `AppendLogs`, which already takes a `LogMessage` the caller built.
- 🚨 **An optional key parameter would have been the wrong shape.** It leaves the helper looking
migrated while the next caller silently re-opens the hole, and the ratchet cannot see past a helper
to the callers behind it. Required-key plus a named verbatim escape hatch makes the un-keyed case a
decision somebody wrote down.
- **A conditional clause gets its own key, never a `{suffix}` argument.** `", repository created"`
spliced into a translated sentence is untranslated English inside German word order, so the two
outcomes are two whole sentences (`commit.done` / `commit.doneRepoCreated`). Same reason the
compile's store-upload warning is `storeUploadTimedOut` / `storeUploadFailed` rather than one key
taking a composed `{reason}`.
- 🚨 **Chain `.WithKey` onto the constructor.** The ratchet's lookahead is anchored to the
constructor's own closing paren, so `var m = new LogMessage(text, level);` followed later by
`m.WithKey(…)` reads as UNKEYED — correctly, because the English and its key are then free to
drift apart, which is the whole thing the fallback exists to prevent.
- **What stays English** is the two populations the allow file names: generic plumbing (an `ILogger`
adapter; the `string title` / `Log(string, level)` spellings kept for a caller whose text is not
the platform's) and verbatim upstream text (`ex.Message`, a Roslyn diagnostic, a descendant hub's
refusal, the composed multi-clause import summaries).

🚨 **Adding an overload to a public helper for this is a cross-repo break.** A dependent's
`<see cref="ActivityRunner.RunActivity"/>` becomes `CS0419` the moment a second overload exists, and
under `-warnaserror` that is an error, not a warning — break shape 3 in
[CrossRepoPairGate](../CrossRepoPairGate), which no gate detects. Land the dependent's
signature-qualified cref FIRST: it resolves against one overload as well as two, so it is correct
before and after (MeshWeaver.Plugins#1338 did this for #3281).

### The catalog has a second home, and it goes stale SILENTLY

The web clients carry their own copy at `MeshWeaver.Plugins/clients/react/src/i18n/strings.{en,de}.json`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
Name: Activity transcripts speak your language
Category: Fix
Description: GitHub sync, compile and import activities recorded every line in English, whatever language you read the portal in. Those transcripts are now written with a catalog key and rendered in your language.
Icon: Bug
Order: -20260904
---

# Activity transcripts speak your language

Open a GitHub sync, a NodeType compile or a Space import and you get a **transcript** — the list of
lines the operation wrote as it ran. Until now every one of those lines was English, for every
viewer, and no setting changed that.

The reason is worth stating, because it is not an oversight anyone could have fixed by translating
harder. A transcript line is written **server-side, at the moment the work happens, with no viewer
in scope** — an import runs as the system at boot, a compile runs on a node hub, a sync runs behind
somebody's click. The line is then read later by several people whose languages differ. Resolving a
language at write time would not have been merely hard; it would have been *wrong*, because it
freezes one reader's language into a record everyone shares.

So the lines are no longer stored as finished sentences. They are stored as a catalog **key** plus
the values that vary — the Space, the commit, the counts — and the language is chosen when the page
is rendered, from the locale of whoever is looking at it. A German viewer now reads:

```
Branch 'main' steht auf a1b2c3d4 — Ihr Space ist aktuell.
a1b2c3d4 committet (12 geschrieben, 3 entfernt).
Die Quellensuche fand 14 Code-Node(s): …
```

Every one of the eight GitHub operations — commit, update, re-import, create branch, open pull
request, check branch, sync issues, merge — is covered, along with the compile pipeline's progress
and failure lines and the import's own progress and content-sync ledger.

**Nothing already recorded has changed.** Every line written before this keeps its stored English
text and renders exactly as it did, as does any line whose text was never the platform's to
translate in the first place: a Roslyn diagnostic, GitHub's own refusal message, an exception. Those
stay verbatim on purpose — a compiler's `CS0246` with its file and span says more to whoever has to
act on it than a translation of the sentence around it would, and the status beside it reads the
same in any language.
92 changes: 71 additions & 21 deletions src/MeshWeaver.GitSync/ActivityRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ namespace MeshWeaver.GitSync;

/// <summary>
/// The progress/cancel surface a command sees while running inside an activity. The command
/// calls <see cref="Log"/> to append a progress line (visible live on the activity node) and
/// calls <see cref="Log(LogMessage)"/> (or <see cref="Log(string, LogLevel)"/>) to append a
/// progress line (visible live on the activity node) and
/// observes <see cref="CancellationToken"/> to honour a user-requested cancel.
/// </summary>
public sealed class ActivityContext
{
private readonly Action<string, LogLevel> append;
private readonly Action<LogMessage> append;

internal ActivityContext(string activityPath, CancellationToken ct, Action<string, LogLevel> append)
internal ActivityContext(string activityPath, CancellationToken ct, Action<LogMessage> append)
{
ActivityPath = activityPath;
CancellationToken = ct;
Expand All @@ -35,8 +36,24 @@ internal ActivityContext(string activityPath, CancellationToken ct, Action<strin
/// <summary>Trips when the user requests cancel (<c>RequestedStatus = Cancelled</c> on the activity).</summary>
public CancellationToken CancellationToken { get; }

/// <summary>Appends a progress line to the activity's <see cref="ActivityLog.Messages"/> (shown live).</summary>
public void Log(string message, LogLevel level = LogLevel.Information) => append(message, level);
/// <summary>
/// Appends a progress line to the activity's <see cref="ActivityLog.Messages"/> (shown live).
///
/// <para>🚨 This overload persists the sentence in ENGLISH and no renderer can translate it
/// afterwards (#3236) — the activity transcript is read later by viewers whose languages differ.
/// Use it only where the text is not the platform's to own (an upstream tool's output, an
/// exception message). Anything the platform composes goes through
/// <see cref="Log(LogMessage)"/> with a catalog key.</para>
/// </summary>
public void Log(string message, LogLevel level = LogLevel.Information) =>
append(new LogMessage(message, level));

/// <summary>
/// Appends a progress line built by the caller — the localizable form. Write it as
/// <c>new LogMessage($"…", level).WithKey("activity.…", ("name", value))</c>: the constructor
/// argument stays the English fallback and the key is what the viewer's language resolves.
/// </summary>
public void Log(LogMessage message) => append(message);
}

/// <summary>
Expand All @@ -49,13 +66,14 @@ internal ActivityContext(string activityPath, CancellationToken ct, Action<strin
/// <see cref="CancellationToken"/> (the standard Activity Control Plane — see
/// <c>Doc/Architecture/ActivityControlPlane.md</c>);</item>
/// <item>runs the command (its I/O already goes through <c>IIoPool</c>, so the action block stays
/// responsive), forwarding <see cref="ActivityContext.Log"/> lines onto the node live;</item>
/// responsive), forwarding <see cref="ActivityContext.Log(LogMessage)"/> lines onto the node
/// live;</item>
/// <item>writes the terminal <c>Status</c> (Succeeded / Failed / Cancelled) + messages in one
/// atomic update.</item>
/// </list>
///
/// <para>🚨 Reactive, no <c>async</c>/<c>await</c>. The public surface is a static
/// <see cref="IMessageHub"/> extension (<see cref="RunActivity"/>): the GUI calls it from a click,
/// <see cref="IMessageHub"/> extension (<see cref="RunActivity(IMessageHub, string, string, LogMessage, Func{ActivityContext, IObservable{Unit}}, Action{string})"/>): the GUI calls it from a click,
/// tests call it in isolation, and both observe progress / drive cancel through the returned
/// activity path. This is the single entry point — there is no per-operation request type.</para>
/// </summary>
Expand All @@ -74,9 +92,32 @@ public static IObservable<string> RunActivity(
string category,
string title,
Func<ActivityContext, IObservable<Unit>> command,
Action<string>? onActivityCreated = null) =>
// The un-keyed spelling: the title persists in English, exactly as it did before #3236.
// Kept for a title that is not the platform's to translate (a test's throwaway op, an
// upstream name); an operation the platform NAMES uses the LogMessage overload below.
hub.RunActivity(partitionPath, category, new LogMessage(title, LogLevel.Information),
command, onActivityCreated);

/// <summary>
/// The localizable form of <see cref="RunActivity(IMessageHub, string, string, string, Func{ActivityContext, IObservable{Unit}}, Action{string})"/>:
/// <paramref name="title"/> carries the English sentence AND its catalog key, so the activity's
/// opening line renders in the viewer's language while the node's <see cref="MeshNode.Name"/>
/// keeps the English text.
///
/// <para>Write it as <c>new LogMessage($"Commit {space} to GitHub", LogLevel.Information)
/// .WithKey("activity.gitsync.commit.title", ("space", space))</c>.</para>
/// </summary>
public static IObservable<string> RunActivity(
this IMessageHub hub,
string partitionPath,
string category,
LogMessage title,
Func<ActivityContext, IObservable<Unit>> command,
Action<string>? onActivityCreated = null)
{
ArgumentNullException.ThrowIfNull(hub);
ArgumentNullException.ThrowIfNull(title);
ArgumentNullException.ThrowIfNull(command);
// 🚨 Explicit owner dependency: the activity is created at {partitionPath}/_Activity/{id} with
// MainNode = partitionPath, so partitionPath MUST be a real, routable owning node. An empty /
Expand All @@ -102,15 +143,18 @@ public static IObservable<string> RunActivity(
var node = new MeshNode(id, $"{partitionPath}/_Activity")
{
NodeType = "Activity",
Name = title,
// The node NAME stays the English sentence: it is a stored field a listing sorts and a
// path-shaped surface echoes, not a rendered line. The transcript's opening entry below
// is what carries the key, and that is what a viewer actually reads.
Name = title.Message,
State = MeshNodeState.Active,
MainNode = partitionPath,
Content = new ActivityLog(category)
{
Id = id,
HubPath = activityPath,
Status = ActivityStatus.Running,
Messages = ImmutableList.Create(new LogMessage(title, LogLevel.Information)),
Messages = ImmutableList.Create(title),
},
};

Expand Down Expand Up @@ -215,12 +259,12 @@ IObservable<string> Run()
activityPath));

var ctx = new ActivityContext(activityPath, cts.Token,
(msg, level) =>
message =>
{
// Every appended line is the run saying "still working" — the quiesce's
// notion of progress is exactly this, never a heartbeat.
runRegistration?.Progress();
Append(workspace, accessService, owner, activityPath, msg, level, logger);
Append(workspace, accessService, owner, activityPath, message, logger);
});

// Run the command; on terminal, write the final Status + dispose watcher/cts.
Expand Down Expand Up @@ -248,9 +292,18 @@ IObservable<string> Run()
cancelled ? ActivityStatus.Cancelled : ActivityStatus.Failed,
cancelled
? (Volatile.Read(ref killedByTeardown) != 0
? "Cancelled by teardown: the run made no progress while the mesh was shutting down."
: "Cancelled.")
: ex.Message, logger);
? new LogMessage(
"Cancelled by teardown: the run made no progress while the mesh was shutting down.",
LogLevel.Information)
.WithKey("activity.run.cancelledByTeardown")
: new LogMessage("Cancelled.", LogLevel.Information)
.WithKey("activity.run.cancelled"))
// 🚨 The command's own failure text, verbatim and therefore English:
// it is an upstream exception message (a GitHub API refusal, a parse
// error), not a sentence this platform composes, so no catalog can
// carry it (#3281). The terminal STATUS beside it is what a viewer
// reads language-independently.
: new LogMessage(ex.Message, LogLevel.Error), logger);
})
.Finally(() =>
{
Expand Down Expand Up @@ -323,7 +376,7 @@ subscribeScheduler is { } scheduler

private static void Append(
IWorkspace workspace, AccessService? accessService, AccessContext? owner,
string activityPath, string message, LogLevel level, ILogger? logger)
string activityPath, LogMessage message, ILogger? logger)
{
// 🚨 Re-establish the owner identity AT THIS write's .Update() invocation.
// ctx.Log fires from the command's own threads (IIoPool body, reactive
Expand All @@ -338,14 +391,14 @@ private static void Append(
using (owner is not null && accessService is not null ? accessService.SwitchAccessContext(owner) : null)
{
ActivityLogAppender
.Append(workspace.Hub, activityPath, [new LogMessage(message, level)], mutate: null, logger)
.Append(workspace.Hub, activityPath, [message], mutate: null, logger)
.Subscribe(_ => { }, ex => logger?.LogWarning(ex, "Activity log append failed for {Path}", activityPath));
}
}

private static IObservable<Unit> Finish(
IWorkspace workspace, AccessService? accessService, AccessContext? owner,
string activityPath, ActivityStatus status, string? finalMessage, ILogger? logger)
string activityPath, ActivityStatus status, LogMessage? finalMessage, ILogger? logger)
{
// 🚨 Same owner re-stamp as Append: the terminal Status write fires on the
// command's completion hop, past the originating scope. Re-establish the owner
Expand All @@ -355,10 +408,7 @@ private static IObservable<Unit> Finish(
{
return ActivityLogAppender.Append(
workspace.Hub, activityPath,
string.IsNullOrEmpty(finalMessage)
? []
: [new LogMessage(finalMessage,
status == ActivityStatus.Failed ? LogLevel.Error : LogLevel.Information)],
finalMessage is null ? [] : [finalMessage],
// Honour what the command reported via ctx.Log: ActivityLog.Finish computes
// MAX(status, severity roll-up) — an Error line a command appended flips a
// would-be Succeeded terminal to Failed, a Warning line to Warning; explicit
Expand Down
Loading
Loading