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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ is not part of this repository.
about three thousand characters. `--json` is unchanged and still carries both whole lists. A snapshot
edited by hand into a trigger this program never writes - an empty one, or data without its type or
values - still shows the two whole lists for that entry.
- A trigger of the kind the Windows SDK calls an aggregate trigger is named *Aggregate*, in
`bws show`, the window, a snapshot and `--json`. It used to be *Unknown* - `sc qtriggerinfo` names no
kind for it either - so a change from it to another kind nobody could name went unseen.
`trigger:aggregate` asks for it, and `trigger:unknown` no longer finds it. Compared with a snapshot
taken by an earlier version, where it is still *Unknown*, it does not show as a change.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions src/Bws.Cli/Resources/cli.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@
"cli.cell.trigger.networkEndpoint": "Network endpoint",
"cli.cell.trigger.custom": "Custom",
"cli.cell.trigger.systemState": "System state",
"cli.cell.trigger.aggregate": "Aggregate",
"cli.cell.startDelayed": "{0} (delayed)",
"cli.cell.startDelayedUnknown": "{0} (delay unknown)",
"cli.cell.startTrigger": "{0}, on trigger",
Expand Down
4 changes: 3 additions & 1 deletion src/Bws.Cli/TriggerWords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace Bws.Cli;
///
/// <b>WRITTEN 2026-09-01 FOR BACKLOG 260</b>, the third and last family with this fault - the
/// argument for all three is in <see cref="StatusWords"/> and is not repeated here. Seven of the
/// nine members read as two or three words jammed together: <c>DeviceArrival</c>,
/// nine members of that day read as two or three words jammed together (<c>Aggregate</c>, the
/// tenth, came on 2026-10-28 for backlog 627 and is one word): <c>DeviceArrival</c>,
/// <c>IpAddress</c>, <c>DomainJoin</c>, <c>FirewallPort</c>, <c>GroupPolicy</c>,
/// <c>NetworkEndpoint</c> and <c>CustomSystemStateChange</c>.
///
Expand Down Expand Up @@ -110,6 +111,7 @@ private static IEnumerable<string> Items(TriggerDocument trigger) =>
TriggerKind.NetworkEndpoint => Texts.Of("cli.cell.trigger.networkEndpoint"),
TriggerKind.Custom => Texts.Of("cli.cell.trigger.custom"),
TriggerKind.CustomSystemStateChange => Texts.Of("cli.cell.trigger.systemState"),
TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"),
_ => Texts.Of("cli.cell.unknown")
};
}
10 changes: 8 additions & 2 deletions src/Bws.Core/ManagerTerms.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,16 @@ internal static class ManagerTerms
// Value seven, which this interop metadata does not name. Identified from the
// machine rather than from memory: sc qtriggerinfo calls every trigger that landed
// here "CUSTOM SYSTEM STATE CHANGE EVENT", and mapping it took the count of kinds
// we could not name from 89 to 1 on a machine with 810 entries. The one left is a
// kind sc.exe cannot name either - it prints the action and no type line at all.
// we could not name from 89 to 1 on a machine with 810 entries.
(SERVICE_TRIGGER_TYPE)7 => TriggerKind.CustomSystemStateChange,

// Value thirty, the one that was left. sc.exe cannot name it either - it prints the
// action and no type line at all - but winsvc.h of the Windows SDK 10.0.28000.0 does,
// as SERVICE_TRIGGER_TYPE_AGGREGATE, on the line after SERVICE_TRIGGER_TYPE_CUSTOM.
// Measured on 2026-10-28: CDPSvc's one trigger, and after it no kind on that machine
// comes back Unknown.
(SERVICE_TRIGGER_TYPE)30 => TriggerKind.Aggregate,

_ => TriggerKind.Unknown
};

Expand Down
1 change: 1 addition & 0 deletions src/Bws.Core/Querying/QueryValueNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ internal static class QueryValueNames
new QueryValueName("network", "networkendpoint"),
new QueryValueName("custom", "custom"),
new QueryValueName("state", "customsystemstatechange"),
new QueryValueName("aggregate", "aggregate"),
new QueryValueName("unknown", "unknown"),

// Not a kind but an action, and worth asking about on its own: a trigger
Expand Down
14 changes: 13 additions & 1 deletion src/Bws.Core/ServiceTrigger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,19 @@ public enum TriggerKind
/// entries. Leaving it as Unknown would have made the second largest group on the
/// machine indistinguishable from anything else we cannot name.
/// </summary>
CustomSystemStateChange
CustomSystemStateChange,

/// <summary>
/// A trigger Windows assembles from others, identified by a subtype - kind thirty.
///
/// The interop metadata has no name for this one either, and sc qtriggerinfo prints no kind for
/// it at all, but winsvc.h of the Windows SDK does: SERVICE_TRIGGER_TYPE_AGGREGATE. Measured on
/// 2026-10-28 it is one trigger on a machine with 252, the one CDPSvc has. Until then it was held
/// as Unknown, which made a change from it to some other kind nobody can name invisible to a
/// comparison. Named inside schema seven, before that schema was released, so a file older than
/// seven is the only one that can still say Unknown for it - see SnapshotDiff.
/// </summary>
Aggregate
}

/// <summary>What the trigger does when it fires.</summary>
Expand Down
13 changes: 12 additions & 1 deletion src/Bws.Core/Snapshots/SnapshotDiff.Values.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,23 @@ .. triggers.Select(trigger => trigger is null
? null
: new JsonObject
{
["kind"] = trigger["kind"]?.DeepClone(),
["kind"] = KindBeforeSeven(trigger["kind"]),
["action"] = trigger["action"]?.DeepClone()
})
]);
}

/// <summary>
/// A kind as a file older than schema seven holds it. Kind thirty was named Aggregate on 2026-10-28
/// (backlog 627), inside schema seven and before it was released, and every build until then wrote it
/// Unknown - so against an older file the new name would read as a change on a machine where nothing
/// changed. Both sides are folded, which costs nothing on the older one: it cannot hold the name.
/// </summary>
private static JsonNode? KindBeforeSeven(JsonNode? kind) =>
kind is JsonValue value && value.TryGetValue<string>(out var text) && text == nameof(TriggerKind.Aggregate)
? JsonValue.Create(nameof(TriggerKind.Unknown))
: kind?.DeepClone();

/// <summary>
/// The triggers each side holds and the other does not, for the triggers field - null for any other.
///
Expand Down
1 change: 1 addition & 0 deletions src/Bws.Gui/Resources/gui.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@
"gui.cell.trigger.networkEndpoint": "Network endpoint",
"gui.cell.trigger.custom": "Custom",
"gui.cell.trigger.systemState": "System state",
"gui.cell.trigger.aggregate": "Aggregate",
"gui.trigger.head": "{0}/{1}",
"gui.trigger.withSubtype": "{0} {1}",
"gui.trigger.withData": "{0} {1}",
Expand Down
1 change: 1 addition & 0 deletions src/Bws.Gui/ViewModels/CellFaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ public static string TriggerList(Reading<IReadOnlyList<ServiceTrigger>> reading)
TriggerKind.NetworkEndpoint => Texts.Of("gui.cell.trigger.networkEndpoint"),
TriggerKind.Custom => Texts.Of("gui.cell.trigger.custom"),
TriggerKind.CustomSystemStateChange => Texts.Of("gui.cell.trigger.systemState"),
TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"),
_ => Texts.Of("gui.cell.unknown")
};

Expand Down
43 changes: 43 additions & 0 deletions tests/Bws.Cli.Tests/TriggerWordsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Bws.Core;

namespace Bws.Cli.Tests;

/// <summary>
/// Every trigger kind the core names has a word of its own in the terminal - its own word, not one it
/// shares with another kind and not the word for a kind nobody can name.
///
/// <b>Written 2026-10-28 for backlog 627</b>, when the core named a tenth kind, Aggregate. The words are a
/// switch with a fallback, so a kind added to the core and forgotten here prints "Unknown" - the very word
/// the new name was added to replace - and nothing else would say so. The first version asked only that,
/// and the review of PR #99 caught what it let through: Aggregate pointed at the word for Custom would have
/// passed. Now the table below pins each word, and the guard after it asks that no two kinds share one, so
/// a kind added later and missing from the table is still held. CellFaceTests asks the same of the window.
/// </summary>
public sealed class TriggerWordsTests
{
[Theory]
[InlineData(TriggerKind.DeviceArrival, "cli.cell.trigger.deviceArrival")]
[InlineData(TriggerKind.IpAddress, "cli.cell.trigger.ipAddress")]
[InlineData(TriggerKind.DomainJoin, "cli.cell.trigger.domainJoin")]
[InlineData(TriggerKind.FirewallPort, "cli.cell.trigger.firewallPort")]
[InlineData(TriggerKind.GroupPolicy, "cli.cell.trigger.groupPolicy")]
[InlineData(TriggerKind.NetworkEndpoint, "cli.cell.trigger.networkEndpoint")]
[InlineData(TriggerKind.Custom, "cli.cell.trigger.custom")]
[InlineData(TriggerKind.CustomSystemStateChange, "cli.cell.trigger.systemState")]
[InlineData(TriggerKind.Aggregate, "cli.cell.trigger.aggregate")]
[InlineData(TriggerKind.Unknown, "cli.cell.unknown")]
public void Each_kind_reads_as_its_word(TriggerKind kind, string key)
{
Assert.Equal(Texts.Of(key), TriggerWords.Of(kind));
}

[Fact]
public void Every_kind_the_core_names_has_a_word_of_its_own()
{
var named = Enum.GetValues<TriggerKind>().Where(kind => kind != TriggerKind.Unknown).ToList();
var words = named.Select(TriggerWords.Of).ToList();

Assert.DoesNotContain(Texts.Of("cli.cell.unknown"), words);
Assert.Equal(named.Count, words.Distinct(StringComparer.Ordinal).Count());
}
}
35 changes: 35 additions & 0 deletions tests/Bws.Core.Tests/TriggerChangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,41 @@ public void Against_an_older_file_a_line_holds_the_kind_and_the_action_alone()
Assert.Equal(new TriggerDocument("CustomSystemStateChange", "Start", null, null), Assert.Single(change.Added));
}

/// <summary>
/// Kind thirty was named Aggregate inside schema seven (backlog 627), and every file older than seven
/// holds it as Unknown - so against such a file the name is folded back, and a machine where nothing
/// changed shows nothing.
/// </summary>
[Fact]
public void Against_an_older_file_an_aggregate_trigger_held_there_as_unknown_is_the_same_trigger()
{
var before = Tree(Six);
Entry(before, "sppsvc")["triggers"]![1]!["kind"] = "Unknown";
var after = Tree(Seven);
Trigger(after, "sppsvc", 1)["kind"] = "Aggregate";

Assert.Empty(Diff(before, after).Changed);
}

/// <summary>
/// The fold is for a file older than seven alone. Two files of schema seven compare the kind as written,
/// so a file a build before backlog 627 wrote shows the change once - the price the owner accepted on
/// 2026-10-28 for naming it without a schema eight.
/// </summary>
[Fact]
public void Between_two_files_of_schema_seven_unknown_and_aggregate_are_two_kinds()
{
var before = Tree(Seven);
Trigger(before, "sppsvc", 1)["kind"] = "Unknown";
var after = Tree(Seven);
Trigger(after, "sppsvc", 1)["kind"] = "Aggregate";

var change = Assert.Single(Assert.Single(Diff(before, after).Changed).Differences).Triggers!;

Assert.Equal("Unknown", Assert.Single(change.Removed).Kind);
Assert.Equal("Aggregate", Assert.Single(change.Added).Kind);
}

/// <summary>Only the triggers field carries the lines - every other difference is its two values.</summary>
[Fact]
public void Only_the_triggers_field_carries_a_trigger_change()
Expand Down
38 changes: 38 additions & 0 deletions tests/Bws.Core.Tests/TriggerKindTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Windows.Win32.System.Services;

namespace Bws.Core.Tests;

/// <summary>
/// The manager's number for a trigger kind, in our words - above all the two numbers the interop
/// metadata does not name.
///
/// <b>Written 2026-10-28 for backlog 627</b>, when kind thirty got the name winsvc.h of the Windows SDK
/// gives it, SERVICE_TRIGGER_TYPE_AGGREGATE. Until then nothing held the mapping of an unnamed number:
/// seven was read right on every real machine, and a change sending it back to Unknown would have
/// passed every test.
/// </summary>
public sealed class TriggerKindTests
{
[Theory]
[InlineData(7u, TriggerKind.CustomSystemStateChange)]
[InlineData(30u, TriggerKind.Aggregate)]
public void A_kind_the_metadata_does_not_name_is_read_by_its_number(uint number, TriggerKind kind)
{
Assert.Equal(kind, ManagerTerms.Trigger((SERVICE_TRIGGER_TYPE)number));
}

/// <summary>
/// A number no header names stays Unknown rather than being folded into a neighbour - the numbers
/// right beside the named ones as winsvc.h lists them: before one, after seven, after twenty and
/// after thirty.
/// </summary>
[Theory]
[InlineData(0u)]
[InlineData(8u)]
[InlineData(21u)]
[InlineData(31u)]
public void A_number_no_header_names_stays_unknown(uint number)
{
Assert.Equal(TriggerKind.Unknown, ManagerTerms.Trigger((SERVICE_TRIGGER_TYPE)number));
}
}
18 changes: 18 additions & 0 deletions tests/Bws.Core.Tests/TriggerQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ public void A_kind_can_be_asked_for_by_name()
Assert.Contains("sppsvc", Match("trigger:state"));
}

/// <summary>
/// Kind thirty, named on 2026-10-28 (backlog 627) after the name winsvc.h gives it. Until that day it
/// answered to <c>trigger:unknown</c> - now it answers to its own word, and no longer to that one.
/// </summary>
[Fact]
public void An_aggregate_trigger_is_asked_for_by_its_own_word()
{
var entry = Specimens.DelayedAutomaticNotRunning with
{
ServiceName = "CDPSvc",
Triggers = Reading<IReadOnlyList<ServiceTrigger>>.Present(
[new ServiceTrigger(TriggerKind.Aggregate, TriggerAction.Start, new Guid("a086ff1e-d6dc-45f7-b3e4-6cd5c9fdd6d7"), [])])
};

Assert.True(QueryParser.Parse("trigger:aggregate").Query!.Match(entry).Matched);
Assert.False(QueryParser.Parse("trigger:unknown").Query!.Match(entry).Matched);
}

[Fact]
public void The_action_is_askable_next_to_the_kind()
{
Expand Down
36 changes: 36 additions & 0 deletions tests/Bws.Gui.Tests/CellFaceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,42 @@ public void The_shape_is_never_the_word()
}
}

/// <summary>
/// Each trigger kind reads as its own word in the window. Written 2026-10-28 for backlog 627, when the
/// core named Aggregate: the label is a switch with a fallback, so a kind forgotten here reads "Unknown"
/// in the column and the panel, and the review of PR #99 caught that a first version asking only that
/// would have let Aggregate point at the word for Custom. TriggerWordsTests asks the same of the terminal.
/// </summary>
[Theory]
[InlineData(TriggerKind.DeviceArrival, "gui.cell.trigger.deviceArrival")]
[InlineData(TriggerKind.IpAddress, "gui.cell.trigger.ipAddress")]
[InlineData(TriggerKind.DomainJoin, "gui.cell.trigger.domainJoin")]
[InlineData(TriggerKind.FirewallPort, "gui.cell.trigger.firewallPort")]
[InlineData(TriggerKind.GroupPolicy, "gui.cell.trigger.groupPolicy")]
[InlineData(TriggerKind.NetworkEndpoint, "gui.cell.trigger.networkEndpoint")]
[InlineData(TriggerKind.Custom, "gui.cell.trigger.custom")]
[InlineData(TriggerKind.CustomSystemStateChange, "gui.cell.trigger.systemState")]
[InlineData(TriggerKind.Aggregate, "gui.cell.trigger.aggregate")]
[InlineData(TriggerKind.Unknown, "gui.cell.unknown")]
public void Each_trigger_kind_reads_as_its_word(TriggerKind kind, string key)
{
Assert.Equal(Bws.Gui.Texts.Of(key), CellFaces.TriggerKindLabel(kind));
}

/// <summary>
/// The guard for a kind added to the core later and missing from the table above: none reads as the
/// word for an unnamed kind, and no two share a word.
/// </summary>
[Fact]
public void Every_trigger_kind_the_core_names_has_a_word_of_its_own()
{
var named = Enum.GetValues<TriggerKind>().Where(kind => kind != TriggerKind.Unknown).ToList();
var words = named.Select(CellFaces.TriggerKindLabel).ToList();

Assert.DoesNotContain(Bws.Gui.Texts.Of("gui.cell.unknown"), words);
Assert.Equal(named.Count, words.Distinct(StringComparer.Ordinal).Count());
}

[Fact]
public void The_start_type_carries_what_changes_its_meaning()
{
Expand Down
64 changes: 64 additions & 0 deletions tests/mutation/entries.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15065,6 +15065,70 @@ internal sealed class SizeValue(NumberOperator operation, long low, long high) :
To = 'private const string ItemSeparator = " / ";'
Test = 'TriggerPartsTests.The_items_of_one_trigger_are_joined_by_a_bar'
}
@{
# Backlog 627: kind thirty is read as Aggregate, the name winsvc.h gives it.
Name = 'triggers: kind thirty is read as aggregate'
File = 'src\Bws.Core\ManagerTerms.cs'
From = '(SERVICE_TRIGGER_TYPE)30 => TriggerKind.Aggregate,'
To = '(SERVICE_TRIGGER_TYPE)30 => TriggerKind.Unknown,'
Test = 'TriggerKindTests.A_kind_the_metadata_does_not_name_is_read_by_its_number'
}
@{
# Backlog 627: seven had no test of its own until the day thirty got one beside it.
Name = 'triggers: kind seven is read as a system state change'
File = 'src\Bws.Core\ManagerTerms.cs'
From = '(SERVICE_TRIGGER_TYPE)7 => TriggerKind.CustomSystemStateChange,'
To = '(SERVICE_TRIGGER_TYPE)7 => TriggerKind.Unknown,'
Test = 'TriggerKindTests.A_kind_the_metadata_does_not_name_is_read_by_its_number'
}
@{
# Backlog 627: against a file older than seven, Aggregate is folded back to the Unknown that file holds.
Name = 'diff: aggregate is folded to unknown against a file older than seven'
File = 'src\Bws.Core\Snapshots\SnapshotDiff.Values.cs'
From = '? JsonValue.Create(nameof(TriggerKind.Unknown))'
To = '? JsonValue.Create(nameof(TriggerKind.Aggregate))'
Test = 'TriggerChangeTests.Against_an_older_file_an_aggregate_trigger_held_there_as_unknown_is_the_same_trigger'
}
@{
# Backlog 627: trigger:aggregate is a word the query language accepts.
Name = 'query: trigger accepts aggregate'
File = 'src\Bws.Core\Querying\QueryValueNames.cs'
From = 'new QueryValueName("aggregate", "aggregate"),'
To = ''
Test = 'TriggerQueryTests.An_aggregate_trigger_is_asked_for_by_its_own_word'
}
@{
# Backlog 627: the terminal has a word for every kind the core names.
Name = 'cli words: aggregate has a word of its own'
File = 'src\Bws.Cli\TriggerWords.cs'
From = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"),'
To = ''
Test = 'TriggerWordsTests.Every_kind_the_core_names_has_a_word_of_its_own'
}
@{
# Backlog 627: the window has a word for every kind the core names.
Name = 'cell faces: aggregate has a word of its own'
File = 'src\Bws.Gui\ViewModels\CellFaces.cs'
From = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"),'
To = ''
Test = 'CellFaceTests.Every_trigger_kind_the_core_names_has_a_word_of_its_own'
}
@{
# Review of PR #99: a kind pointed at another kind's word is caught, not only a kind with no word.
Name = "cli words: aggregate does not borrow custom's word"
File = 'src\Bws.Cli\TriggerWords.cs'
From = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.aggregate"),'
To = 'TriggerKind.Aggregate => Texts.Of("cli.cell.trigger.custom"),'
Test = 'TriggerWordsTests.Every_kind_the_core_names_has_a_word_of_its_own'
}
@{
# Review of PR #99: the same in the window.
Name = "cell faces: aggregate does not borrow custom's word"
File = 'src\Bws.Gui\ViewModels\CellFaces.cs'
From = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.aggregate"),'
To = 'TriggerKind.Aggregate => Texts.Of("gui.cell.trigger.custom"),'
Test = 'CellFaceTests.Every_trigger_kind_the_core_names_has_a_word_of_its_own'
}
)

$mutations
Loading