Initial commit: Wonderware / System Platform tools and reference

Five tools under one repo, all docs organized per DOCS-GUIDE.md:

- aalogcli: .NET 4.8 / x86 CliFx CLI for reading System Platform binary
  logs (*.aaLGX) for LLM debugging, built on aaOpenSource/aaLog. Commands:
  last, tail, range, unread, fields. Stable JSON envelope under --llm-json.
  Build template under lib/build/ for rebuilding aaLogReader.dll.

- aot: ArchestrA Object Toolkit 2014 v4.0 reference material. Dev guide
  (Markdown converted from CHM), API reference for the ArchestrA.Toolkit
  namespace, and the Monitor / Watchdog VS sample solutions.

- graccesscli: .NET 4.8 / x86 CliFx CLI that automates Galaxy
  configuration via the ArchestrA GRAccess COM interop. Includes session
  daemon, IPC protocol, and llm-json envelope contract.

- grdb: SQL/DDL exploration of the Galaxy Repository database. DDL
  captures, reusable queries, hierarchy / contained-name <-> tag-name
  translation notes.

- histdb: LLM-oriented reference for AVEVA Historian retrieval. INSQL
  linked-server, extension tables, every wwXxx time-domain extension,
  every retrieval mode, alarm/event SQL recipes, REST API. Distilled
  from the 243-page Historian Retrieval Guide.

Root contains:
- CLAUDE.md: thin index pointing into each tool's README.
- DOCS-GUIDE.md: doctrine for organizing docs for LLM consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-03 18:22:20 -04:00
commit 32f26272ae
411 changed files with 69973 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net48</TargetFramework>
<Platforms>x86</Platforms>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
<RootNamespace>AaLog.Cli</RootNamespace>
<AssemblyName>aalog</AssemblyName>
<LangVersion>9.0</LangVersion>
<Nullable>disable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="log4net" Version="2.0.15" />
</ItemGroup>
<ItemGroup>
<Reference Include="aaLogReader">
<HintPath>..\..\lib\aaLogReader.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
</Project>
@@ -0,0 +1,28 @@
using CliFx.Attributes;
namespace AaLog.Cli.Commands
{
/// Shared option set inherited by every read command. Kept as an abstract base so
/// CliFx still treats each subclass as a distinct command, but option declarations
/// only live in one place.
public abstract class ReadCommandBase
{
[CommandOption("log-dir", Description = "Override the log directory. Defaults to C:\\ProgramData\\ArchestrA\\LogFiles.")]
public string LogDirectory { get; init; }
[CommandOption("component", Description = "Substring (or regex with --regex) to match against the Component field.")]
public string Component { get; init; }
[CommandOption("level", Description = "Substring (or regex with --regex) to match against the Level / LogFlag field (Info, Warning, Error, ...).")]
public string Level { get; init; }
[CommandOption("message", Description = "Substring (or regex with --regex) to match against the Message body.")]
public string Message { get; init; }
[CommandOption("regex", Description = "Treat --component / --level / --message as regular expressions instead of substrings.")]
public bool UseRegex { get; init; }
[CommandOption("llm-json", Description = "Emit a stable JSON envelope { query, count, records } instead of human-readable lines.")]
public bool LlmJson { get; init; }
}
}
@@ -0,0 +1,33 @@
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace AaLog.Cli.Commands
{
/// Quick field-reference printout so an agent can discover output shape without
/// having to read the docs/ folder. Mirrors LogRecordDto exactly.
[Command("fields", Description = "Print the LogRecord JSON field reference and exit.")]
public sealed class FieldsCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
console.Output.WriteLine("LogRecord fields emitted by aalog (subset of aaLogReader.LogRecord):");
console.Output.WriteLine();
console.Output.WriteLine(" MessageNumber ulong Monotonic record id assigned by the logger.");
console.Output.WriteLine(" TimestampUtc string Event time, ISO-8601 with Z suffix.");
console.Output.WriteLine(" TimestampLocal string Event time in the host's local zone, ISO-8601.");
console.Output.WriteLine(" Level string LogFlag value: Info, Warning, Error, Trace, ...");
console.Output.WriteLine(" Component string Originating component (e.g. Bootstrap, aaEngine).");
console.Output.WriteLine(" ProcessName string Process that emitted the record.");
console.Output.WriteLine(" ProcessId uint OS process id.");
console.Output.WriteLine(" ThreadId uint OS thread id.");
console.Output.WriteLine(" SessionId string Session id, when present.");
console.Output.WriteLine(" Host string Host FQDN at time of emission.");
console.Output.WriteLine(" Message string Free-form message body.");
console.Output.WriteLine();
console.Output.WriteLine("LLM-JSON envelope shape: { query: {...}, count: N, records: [LogRecord, ...] }");
return default;
}
}
}
@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AaLog.Cli.Filtering;
using AaLog.Cli.Output;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace AaLog.Cli.Commands
{
[Command("last", Description = "Fetch the most recent N records ending at 'now' (or --until).")]
public sealed class LastCommand : ReadCommandBase, ICommand
{
[CommandOption("count", 'n', Description = "Number of records to fetch (most recent first). Default 50.")]
public int Count { get; init; } = 50;
[CommandOption("until", Description = "Anchor timestamp for the 'end' of the window (ISO-8601, local time). Defaults to now.")]
public DateTime? Until { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
var endTimestamp = Until ?? DateTime.Now;
using var reader = LogReaderFactory.Open(LogDirectory);
var raw = reader.GetRecordsByEndTimestampAndCount(endTimestamp, Count) ?? new List<aaLogReader.LogRecord>();
// Library returns newest-first when fetched by end-timestamp; keep that for human reading.
var dtos = raw.Select(LogRecordDto.From);
var filtered = RecordFilter.Apply(dtos, Component, Level, Message, UseRegex);
if (LlmJson)
{
var query = new
{
command = "last",
count = Count,
until = endTimestamp.ToString("yyyy-MM-ddTHH:mm:ss"),
component = Component,
level = Level,
message = Message,
regex = UseRegex,
log_dir = reader.Options.LogDirectory,
};
OutputWriter.WriteLlmJson(console, query, filtered);
}
else
{
OutputWriter.WriteHuman(console, filtered);
}
return default;
}
}
}
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AaLog.Cli.Filtering;
using AaLog.Cli.Output;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
namespace AaLog.Cli.Commands
{
[Command("range", Description = "Fetch records between explicit start and end timestamps.")]
public sealed class RangeCommand : ReadCommandBase, ICommand
{
[CommandOption("from", IsRequired = true, Description = "Start timestamp (ISO-8601 local time, e.g. 2026-05-03T14:00:00).")]
public DateTime From { get; init; }
[CommandOption("to", Description = "End timestamp (ISO-8601 local time). Defaults to now.")]
public DateTime? To { get; init; }
[CommandOption("max", Description = "Hard cap on records returned. Default 1000.")]
public int Max { get; init; } = 1000;
public ValueTask ExecuteAsync(IConsole console)
{
var to = To ?? DateTime.Now;
if (to <= From)
throw new CommandException("--to must be later than --from.", 2);
using var reader = LogReaderFactory.Open(LogDirectory);
var raw = reader.GetRecordsByStartAndEndTimeStamp(From, to) ?? new List<aaLogReader.LogRecord>();
var ordered = raw.OrderByDescending(r => r.EventDateTimeUtc).Take(Max);
var dtos = ordered.Select(LogRecordDto.From);
var filtered = RecordFilter.Apply(dtos, Component, Level, Message, UseRegex);
if (LlmJson)
{
var query = new
{
command = "range",
from = From.ToString("yyyy-MM-ddTHH:mm:ss"),
to = to.ToString("yyyy-MM-ddTHH:mm:ss"),
max = Max,
component = Component,
level = Level,
message = Message,
regex = UseRegex,
log_dir = reader.Options.LogDirectory,
};
OutputWriter.WriteLlmJson(console, query, filtered);
}
else
{
OutputWriter.WriteHuman(console, filtered);
}
return default;
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AaLog.Cli.Filtering;
using AaLog.Cli.Output;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
namespace AaLog.Cli.Commands
{
[Command("tail", Description = "Fetch records from the last N minutes.")]
public sealed class TailCommand : ReadCommandBase, ICommand
{
[CommandOption("minutes", 'm', Description = "How many minutes back from now to read. Default 10.")]
public int Minutes { get; init; } = 10;
[CommandOption("max", Description = "Hard cap on records returned to keep LLM payloads bounded. Default 1000.")]
public int Max { get; init; } = 1000;
public ValueTask ExecuteAsync(IConsole console)
{
if (Minutes <= 0)
throw new CommandException("--minutes must be a positive integer.", 2);
var end = DateTime.Now;
var start = end.AddMinutes(-Minutes);
using var reader = LogReaderFactory.Open(LogDirectory);
var raw = reader.GetRecordsByStartAndEndTimeStamp(start, end) ?? new List<aaLogReader.LogRecord>();
// Newest first, then cap.
var ordered = raw.OrderByDescending(r => r.EventDateTimeUtc).Take(Max);
var dtos = ordered.Select(LogRecordDto.From);
var filtered = RecordFilter.Apply(dtos, Component, Level, Message, UseRegex);
if (LlmJson)
{
var query = new
{
command = "tail",
minutes = Minutes,
start = start.ToString("yyyy-MM-ddTHH:mm:ss"),
end = end.ToString("yyyy-MM-ddTHH:mm:ss"),
max = Max,
component = Component,
level = Level,
message = Message,
regex = UseRegex,
log_dir = reader.Options.LogDirectory,
};
OutputWriter.WriteLlmJson(console, query, filtered);
}
else
{
OutputWriter.WriteHuman(console, filtered);
}
return default;
}
}
}
@@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AaLog.Cli.Filtering;
using AaLog.Cli.Output;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace AaLog.Cli.Commands
{
[Command("unread", Description = "Fetch records the cache has not yet seen. Useful for incremental polling.")]
public sealed class UnreadCommand : ReadCommandBase, ICommand
{
[CommandOption("max", Description = "Maximum number of unread records to return. Default 1000.")]
public ulong Max { get; init; } = 1000;
[CommandOption("ignore-cache", Description = "Re-read regardless of the cache file. The next call will pick up from the new high-water mark.")]
public bool IgnoreCache { get; init; }
[CommandOption("client-id", Description = "Optional client ID. Use distinct IDs to maintain independent cache files for parallel consumers.")]
public string ClientId { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
using var reader = LogReaderFactory.Open(LogDirectory);
var raw = reader.GetUnreadRecords(Max, "", IgnoreCache, ClientId)
?? new List<aaLogReader.LogRecord>();
var dtos = raw.OrderByDescending(r => r.EventDateTimeUtc).Select(LogRecordDto.From);
var filtered = RecordFilter.Apply(dtos, Component, Level, Message, UseRegex);
if (LlmJson)
{
var query = new
{
command = "unread",
max = Max,
ignore_cache = IgnoreCache,
client_id = ClientId,
component = Component,
level = Level,
message = Message,
regex = UseRegex,
log_dir = reader.Options.LogDirectory,
};
OutputWriter.WriteLlmJson(console, query, filtered);
}
else
{
OutputWriter.WriteHuman(console, filtered);
}
return default;
}
}
}
@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using AaLog.Cli.Output;
namespace AaLog.Cli.Filtering
{
/// Client-side filtering applied after fetch. The aaLogReader library has its own
/// LogRecordPostFilters facility, but rolling our own keeps the surface flat and
/// avoids leaking library structs into the CLI argument layer.
public static class RecordFilter
{
public static IReadOnlyList<LogRecordDto> Apply(
IEnumerable<LogRecordDto> records,
string componentPattern,
string levelPattern,
string messagePattern,
bool useRegex)
{
Predicate<string> componentMatch = Build(componentPattern, useRegex);
Predicate<string> levelMatch = Build(levelPattern, useRegex);
Predicate<string> messageMatch = Build(messagePattern, useRegex);
return records
.Where(r => componentMatch(r.Component ?? string.Empty))
.Where(r => levelMatch(r.Level ?? string.Empty))
.Where(r => messageMatch(r.Message ?? string.Empty))
.ToList();
}
private static Predicate<string> Build(string pattern, bool useRegex)
{
if (string.IsNullOrEmpty(pattern)) return _ => true;
if (useRegex)
{
var rx = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
return s => rx.IsMatch(s);
}
return s => s.IndexOf(pattern, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
}
+5
View File
@@ -0,0 +1,5 @@
// Polyfill so C# 9.0 `init` accessors compile on net48.
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}
@@ -0,0 +1,19 @@
using aaLogReader;
namespace AaLog.Cli
{
/// One place to construct an aaLogReader so every command honors --log-dir the
/// same way and inherits the library's defaults otherwise.
internal static class LogReaderFactory
{
public static aaLogReader.aaLogReader Open(string logDirectoryOverride)
{
var options = new OptionsStruct();
if (!string.IsNullOrWhiteSpace(logDirectoryOverride))
{
options.LogDirectory = logDirectoryOverride;
}
return new aaLogReader.aaLogReader(options);
}
}
}
@@ -0,0 +1,37 @@
using aaLogReader;
namespace AaLog.Cli.Output
{
/// LLM-friendly subset of the underlying aaLogReader record. Drops file-format
/// internals (record length, offsets) and the date/time/millis triple that is
/// redundant with the full ISO-8601 timestamps.
public class LogRecordDto
{
public ulong MessageNumber { get; init; }
public string TimestampUtc { get; init; }
public string TimestampLocal { get; init; }
public string Level { get; init; }
public string Component { get; init; }
public string ProcessName { get; init; }
public uint ProcessId { get; init; }
public uint ThreadId { get; init; }
public string SessionId { get; init; }
public string Host { get; init; }
public string Message { get; init; }
public static LogRecordDto From(LogRecord r) => new LogRecordDto
{
MessageNumber = r.MessageNumber,
TimestampUtc = r.EventDateTimeUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
TimestampLocal = r.EventDateTimeLocal.ToString("yyyy-MM-ddTHH:mm:ss.fff"),
Level = r.LogFlag,
Component = r.Component,
ProcessName = r.ProcessName,
ProcessId = r.ProcessID,
ThreadId = r.ThreadID,
SessionId = r.SessionID,
Host = r.HostFQDN,
Message = r.Message,
};
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using CliFx.Infrastructure;
using Newtonsoft.Json;
namespace AaLog.Cli.Output
{
/// Two output modes:
/// - Human: single-line per record, easy to scan in a terminal.
/// - LlmJson: stable envelope { query, count, records } for agent consumption.
public static class OutputWriter
{
private static readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Include,
Formatting = Formatting.Indented,
};
public static void WriteHuman(IConsole console, IReadOnlyList<LogRecordDto> records)
{
foreach (var r in records)
{
console.Output.WriteLine(
$"[{r.TimestampLocal}] [{r.Level,-7}] {r.Component} ({r.ProcessName}#{r.ProcessId}/{r.ThreadId}) | {r.Message}");
}
}
public static void WriteLlmJson(IConsole console, object query, IReadOnlyList<LogRecordDto> records)
{
var envelope = new
{
query,
count = records.Count,
records,
};
console.Output.WriteLine(JsonConvert.SerializeObject(envelope, JsonSettings));
}
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using CliFx;
namespace AaLog.Cli
{
public static class Program
{
public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder()
.SetTitle("aalog")
.SetExecutableName("aalog")
.SetDescription("Read AVEVA / Wonderware System Platform binary log records.")
.AddCommandsFromThisAssembly()
.Build()
.RunAsync(args);
}
}