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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user