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
+83
View File
@@ -0,0 +1,83 @@
# AGENTS.md
Guidance for coding agents working in the `aalogcli` folder.
## Project Snapshot
This folder contains `aalogcli` (assembly name `aalog`), a `.NET Framework 4.8 / x86` CliFx-based CLI for reading AVEVA / Wonderware System Platform binary log files (`*.aaLGX`). It wraps the third-party reader library [`aaOpenSource/aaLog`](https://github.com/aaOpenSource/aaLog) and exposes it as `last`, `tail`, `range`, `unread`, and `fields` commands tailored for LLM-driven debugging (stable JSON envelope, bounded payloads, post-fetch filters).
For end-to-end command reference and examples, read [`docs/usage.md`](docs/usage.md). For the JSON shape of an emitted record, read [`docs/fields.md`](docs/fields.md). For the upstream reader's API (used by [`LogReaderFactory.cs`](src/AaLog.Cli/LogReaderFactory.cs) and the commands), see the [aaLog README](https://github.com/aaOpenSource/aaLog).
## Key Documentation
All paths are relative to this `aalogcli/` folder.
- [`README.md`](README.md) — tool entry point, hard constraints, build instructions.
- [`docs/usage.md`](docs/usage.md) — every command, every option, with worked examples for the LLM-JSON envelope.
- [`docs/fields.md`](docs/fields.md) — `LogRecordDto` field reference; the canonical shape of records in `--llm-json` output.
## Repository Layout
```text
aalogcli/
AaLog.Cli.slnx
lib/aaLogReader.dll (provisioned out-of-band — see README)
src/AaLog.Cli/
AaLog.Cli.csproj
Program.cs
LogReaderFactory.cs
Commands/
CommonOptions.cs (ReadCommandBase — shared options)
LastCommand.cs (`last` — last N records)
TailCommand.cs (`tail` — last N minutes)
RangeCommand.cs (`range` — explicit start/end)
UnreadCommand.cs (`unread`— incremental, cache-backed)
FieldsCommand.cs (`fields`— field reference printout)
Output/
LogRecordDto.cs (LLM-friendly subset of aaLogReader.LogRecord)
OutputWriter.cs (human single-line + llm-json envelope)
Filtering/
RecordFilter.cs (substring / regex over Component, Level, Message)
IsExternalInit.cs (C# 9 `init` polyfill for net48)
```
## Build And Test
Run commands from this `aalogcli` folder unless noted otherwise.
```powershell
dotnet build src/AaLog.Cli/AaLog.Cli.csproj -p:Platform=x86 -c Release
dotnet run --project src/AaLog.Cli/AaLog.Cli.csproj -- <args>
```
There is no test project at present. If you add one, mirror `graccesscli`'s convention: `tests/AaLog.Cli.Tests/AaLog.Cli.Tests.csproj`, `net48` / `x86`, xunit + Shouldly.
## Implementation Rules
- Keep the CLI targeting `net48` and `x86`. The upstream `aaLogReader` is net40 — net48 loads it cleanly, .NET 10 / x64 will not.
- Do **not** add a `[STAThread]` apartment requirement. Unlike `graccesscli`, `aaLogReader` is plain managed file I/O and runs fine on any thread.
- Construct readers through [`LogReaderFactory.Open`](src/AaLog.Cli/LogReaderFactory.cs) so every command honors `--log-dir` the same way.
- Always wrap the reader in `using` — it implements `IDisposable` and holds a `FileStream`.
- Treat the upstream library as authoritative for log decoding. Do not reimplement aaLGX parsing in this CLI.
- Filtering is **client-side**, after the library returns records. Do not push filter strings into `OptionsStruct.LogRecordPostFilters` from the CLI layer — surface stays flatter that way.
- Every read command must inherit [`ReadCommandBase`](src/AaLog.Cli/Commands/CommonOptions.cs) so `--log-dir` / `--component` / `--level` / `--message` / `--regex` / `--llm-json` stay consistent.
- `--llm-json` envelope shape is `{ query: {...}, count: N, records: [LogRecordDto, ...] }`. Preserve the envelope contract — agents may parse it positionally.
- `LogRecordDto` is the JSON contract. Adding a field is a non-breaking change; renaming or removing one is breaking — bump the docs in the same commit and call it out.
- C# language version is 9.0. `init` is supported through `IsExternalInit.cs`; do not use the `required` keyword.
- Use CliFx command patterns: `[Command]`, `[CommandOption]`, classes implement `ICommand`.
## Output Contracts
| Mode | Trigger | Shape |
| --- | --- | --- |
| Human | default | One line per record: `[localTs] [level] component (process#pid/tid) \| message` |
| LLM-JSON | `--llm-json` | `{ "query": {...}, "count": N, "records": [LogRecordDto, ...] }` |
The `query` object echoes the invocation parameters so an agent reading the JSON can confirm which window it actually got. `log_dir` in the query is the **resolved** directory (post-`--log-dir` override), so it doubles as a sanity check.
## Adding A New Command
1. Add `Commands/<Name>Command.cs` inheriting `ReadCommandBase` and implementing `ICommand`.
2. If it introduces a new query verb (e.g. `since-message-number`), wrap the corresponding `aaLogReader` method in `LogReaderFactory` or inline; document the verb in [`docs/usage.md`](docs/usage.md).
3. Add a row in [`README.md`](README.md) and [`docs/usage.md`](docs/usage.md). Do not modify `../CLAUDE.md` — the root index points at this README and that is sufficient.
4. Keep `--llm-json` envelope semantics identical across commands (`query`, `count`, `records`).
+5
View File
@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/AaLog.Cli/AaLog.Cli.csproj" />
</Folder>
</Solution>
+95
View File
@@ -0,0 +1,95 @@
# aalogcli
A `.NET Framework 4.8 / x86` CLI for reading the AVEVA / Wonderware **System Platform** binary log files (`*.aaLGX`) — built on the [aaOpenSource/aaLog](https://github.com/aaOpenSource/aaLog) reader library and tuned for LLM-driven debugging (last N records, last N minutes, explicit time ranges, incremental "unread" polling, JSON envelope output).
## Hard constraints
- **Default log directory is `C:\ProgramData\ArchestrA\LogFiles`**, which only exists on a host running System Platform / Application Server. Override with `--log-dir <path>` when reading copied logs.
- **`lib/aaLogReader.dll` must be provisioned before building** — `aaLog` is not on NuGet. See [Provisioning aaLogReader.dll](#provisioning-aalogreaderdll).
- **Target framework is `net48`, platform `x86`** to match the upstream library and stay aligned with `graccesscli`. Do not retarget to .NET 10 / x64.
- **Filtering is post-fetch**, so `--component` / `--level` / `--message` narrow the result set the library returns; they do not push down into the binary scan.
## Layout
```text
aalogcli/
AaLog.Cli.slnx
README.md this file
AGENTS.md agent guidance for working in this folder
lib/
aaLogReader.dll (not committed — see provisioning section)
src/AaLog.Cli/
AaLog.Cli.csproj
Program.cs
LogReaderFactory.cs
Commands/ last, tail, range, unread, fields
Output/ LogRecordDto, OutputWriter (human + llm-json)
Filtering/ post-fetch substring/regex filter
docs/
usage.md command surface, options, examples
fields.md LogRecord JSON field reference
```
## Resource index — by task
| Task | Go to |
| --- | --- |
| Agent rules for editing this CLI | [`AGENTS.md`](AGENTS.md) |
| Run the CLI / option reference / example invocations | [`docs/usage.md`](docs/usage.md) |
| `LogRecord` JSON field shape and types | [`docs/fields.md`](docs/fields.md) |
| Upstream reader library (license, source, build) | [aaOpenSource/aaLog on GitHub](https://github.com/aaOpenSource/aaLog) |
| LLM JSON envelope contract | [`docs/usage.md`](docs/usage.md#llm-json-envelope) |
## Provisioning `aaLogReader.dll`
The upstream library is GitHub-only, targets .NET 4.0, and ships with a legacy MSBuild `.csproj` plus a `packages.config` — none of which the modern .NET SDK 10 build pipeline restores cleanly. The recipe below rebuilds it as an SDK-style net48 project; this is the path that's been verified end-to-end on this repo.
If you have Visual Studio with full MSBuild + NuGet on PATH and prefer the upstream csproj as-is, that works too — just produce `aaLogReader.dll` somehow and drop it in `lib/`.
### SDK-style rebuild recipe (verified)
```powershell
# 1. Clone upstream sources somewhere outside this repo:
git clone https://github.com/aaOpenSource/aaLog.git $env:TEMP\aaLog
# 2. Create a sibling build folder with the SDK csproj from this repo's
# aalogcli/lib/build/aaLogReader.csproj template (see below).
mkdir $env:TEMP\aaLogReader-build
copy <wwtools>\aalogcli\lib\build\aaLogReader.csproj $env:TEMP\aaLogReader-build\
# 3. Two source files need a one-line patch — they reference [CallerMemberName]
# behind an #if NET45_OR_GREATER guard but never `using System.Runtime.CompilerServices;`.
# Copy the templates from this repo's lib/build/patched/ into a sibling folder:
mkdir $env:TEMP\aaLogReader-build\patched
copy <wwtools>\aalogcli\lib\build\patched\*.cs $env:TEMP\aaLogReader-build\patched\
# 4. Build and copy out:
cd $env:TEMP\aaLogReader-build
dotnet build -c Release
copy bin\Release\net48\aaLogReader.dll <wwtools>\aalogcli\lib\aaLogReader.dll
```
The csproj template sets `<Deterministic>false</Deterministic>` (the upstream `AssemblyInfo.cs` uses `[assembly: AssemblyVersion("1.0.*")]`) and pins `Newtonsoft.Json 13.0.3` / `log4net 2.0.15` to match what `aalog` itself uses, so there are no transitive-version conflicts at runtime.
`aaLogReader.dll` only needs to land in `lib/`. `Newtonsoft.Json.dll` and `log4net.dll` are pulled in via NuGet by `aalog` itself and end up next to `aalog.exe` automatically.
## Build & run
```powershell
dotnet build src/AaLog.Cli/AaLog.Cli.csproj -p:Platform=x86 -c Release
# Last 50 records, human readable:
dotnet run --project src/AaLog.Cli/AaLog.Cli.csproj -- last
# Last 5 minutes, error-level only, JSON envelope for an LLM:
dotnet run --project src/AaLog.Cli/AaLog.Cli.csproj -- tail --minutes 5 --level Error --llm-json
# Explicit range with regex message filter:
dotnet run --project src/AaLog.Cli/AaLog.Cli.csproj -- range --from 2026-05-03T08:00 --to 2026-05-03T09:00 --message "Galaxy.*timeout" --regex --llm-json
```
The built executable is `bin\Release\net48\aalog.exe` — drop it on `PATH` and use `aalog last -n 100`.
## Maintenance
This README follows the project doctrine in [`../DOCS-GUIDE.md`](../DOCS-GUIDE.md). When you add a command, an option, or a new field to the DTO, update [`docs/usage.md`](docs/usage.md) and [`docs/fields.md`](docs/fields.md) in the same change. The root [`../CLAUDE.md`](../CLAUDE.md) holds one row pointing at this README — it should not need to change unless the tool's task surface changes.
+33
View File
@@ -0,0 +1,33 @@
# aalog — LogRecord field reference
The `--llm-json` envelope emits records of shape `LogRecordDto`, an LLM-friendly subset of the upstream [`aaLogReader.LogRecord`](https://github.com/aaOpenSource/aaLog/blob/master/aaLogReader/Types/LogRecord.cs) (declared in the bare `aaLogReader` namespace despite the file path). File-format internals (`RecordLength`, `OffsetToPrevRecord`, `OffsetToNextRecord`) and the redundant `EventDate` / `EventTime` / `EventMillisec` triple are dropped.
## Fields
| Field | Type | Source | Meaning |
| --- | --- | --- | --- |
| `MessageNumber` | `ulong` | `LogRecord.MessageNumber` | Monotonic record id assigned by the logger. Stable across reads of the same log directory; useful as a cursor. |
| `TimestampUtc` | `string` | `LogRecord.EventDateTimeUtc` | Event time in UTC, ISO-8601 with `Z` suffix and millisecond precision (`2026-05-03T13:59:42.117Z`). |
| `TimestampLocal` | `string` | `LogRecord.EventDateTimeLocal`| Event time in the host's local zone, ISO-8601 without offset (`2026-05-03T08:59:42.117`). |
| `Level` | `string` | `LogRecord.LogFlag` | Severity / category. Common values: `Info`, `Warning`, `Error`, `Trace`, `Debug`. |
| `Component` | `string` | `LogRecord.Component` | Originating subsystem (e.g. `aaEngine`, `Bootstrap`, `aaGR`, `aaIDE`). Best filter axis for narrowing scope. |
| `ProcessName` | `string` | `LogRecord.ProcessName` | Name of the OS process that emitted the record. |
| `ProcessId` | `uint` | `LogRecord.ProcessID` | OS process id. |
| `ThreadId` | `uint` | `LogRecord.ThreadID` | OS thread id within the process. |
| `SessionId` | `string` | `LogRecord.SessionID` | Session identifier; often empty. |
| `Host` | `string` | `LogRecord.HostFQDN` | Fully-qualified host name at time of emission. |
| `Message` | `string` | `LogRecord.Message` | Free-form message body. |
## Backward / forward compatibility
- **Adding a new field** to `LogRecordDto` is non-breaking. Agents that pin the existing field set continue to work.
- **Renaming or removing a field** is breaking. If it ever happens, bump this doc and the `--llm-json` example in [`usage.md`](usage.md) in the same change.
- The upstream library's underlying `LogRecord` may change shape across `aaLogReader.dll` versions. The CLI shields callers from that — only fields mapped explicitly in `LogRecordDto.From` ([`src/AaLog.Cli/Output/LogRecordDto.cs`](../src/AaLog.Cli/Output/LogRecordDto.cs)) reach the JSON envelope.
## Quick reference at runtime
```powershell
aalog fields
```
prints the same field list to stdout in plain text — useful when the caller can't load this file.
+137
View File
@@ -0,0 +1,137 @@
# aalog — usage
Command surface for the `aalog` CLI. The tool reads AVEVA / Wonderware System Platform binary logs (`*.aaLGX`) under `C:\ProgramData\ArchestrA\LogFiles` (override with `--log-dir`).
## Common options
Inherited by `last`, `tail`, `range`, and `unread`:
| Option | Default | Notes |
| --- | --- | --- |
| `--log-dir <path>` | `C:\ProgramData\ArchestrA\LogFiles` | Read from a copied-out log directory. |
| `--component <pattern>` | (none) | Substring match against `Component`. With `--regex`, treated as a regex. |
| `--level <pattern>` | (none) | Substring/regex against `LogFlag` (`Info`, `Warning`, `Error`, `Trace`, …). |
| `--message <pattern>` | (none) | Substring/regex against the message body. |
| `--regex` | off | Switch all three pattern options to regex (case-insensitive). |
| `--llm-json` | off | Emit the stable JSON envelope instead of human-readable lines. |
## Commands
### `aalog last`
Most recent N records ending at `now` (or `--until`).
| Option | Default | Notes |
| --- | --- | --- |
| `-n`, `--count <int>` | `50` | How many records to return. |
| `--until <iso>` | now | ISO-8601 local time anchor for the end of the window. |
```powershell
aalog last # last 50, human readable
aalog last -n 200 --llm-json # last 200, JSON envelope
aalog last --component aaEngine --level Error -n 100
```
### `aalog tail`
Records from the last N minutes.
| Option | Default | Notes |
| --- | --- | --- |
| `-m`, `--minutes <int>` | `10` | Window length in minutes. Must be positive. |
| `--max <int>` | `1000` | Hard cap on records returned (keeps LLM payloads bounded). |
```powershell
aalog tail # last 10 minutes
aalog tail -m 60 --level Error --llm-json # last hour, errors only, JSON
aalog tail -m 5 --message "checkpoint failed" --regex
```
### `aalog range`
Explicit start/end timestamps.
| Option | Default | Notes |
| --- | --- | --- |
| `--from <iso>` | (required) | Start timestamp, ISO-8601 local time. |
| `--to <iso>` | now | End timestamp, ISO-8601 local time. Must be later than `--from`. |
| `--max <int>` | `1000` | Hard cap on records returned. |
```powershell
aalog range --from 2026-05-03T08:00 --to 2026-05-03T09:00 --llm-json
aalog range --from 2026-05-03T14:30:00 --component "Galaxy" --level Warning
```
### `aalog unread`
Incremental polling. Uses the upstream library's cache file (under `%LOCALAPPDATA%\aaLogReader\` by default) to remember the last record ID seen, so successive invocations only return what's new.
| Option | Default | Notes |
| --- | --- | --- |
| `--max <ulong>` | `1000` | Maximum unread records to return. |
| `--ignore-cache` | off | Re-read regardless; the next call resumes from the new high-water mark. |
| `--client-id <name>` | (none) | Distinct cache files for parallel consumers. |
```powershell
aalog unread --llm-json # everything new since last call
aalog unread --client-id watchdog --max 200 # independent cache for this consumer
```
### `aalog fields`
Print the LogRecord JSON field reference and exit. Same content as [`fields.md`](fields.md).
```powershell
aalog fields
```
## LLM-JSON envelope
When `--llm-json` is set, every read command writes a single JSON document to stdout:
```json
{
"query": {
"command": "tail",
"minutes": 5,
"start": "2026-05-03T08:55:00",
"end": "2026-05-03T09:00:00",
"max": 1000,
"component": null,
"level": "Error",
"message": null,
"regex": false,
"log_dir": "C:\\ProgramData\\ArchestrA\\LogFiles"
},
"count": 3,
"records": [
{
"MessageNumber": 18234021,
"TimestampUtc": "2026-05-03T13:59:42.117Z",
"TimestampLocal": "2026-05-03T08:59:42.117",
"Level": "Error",
"Component": "aaEngine",
"ProcessName": "aaEngine",
"ProcessId": 4128,
"ThreadId": 6884,
"SessionId": "",
"Host": "PROD-AS-01.example.local",
"Message": "Galaxy connection timeout after 30000ms"
}
]
}
```
### Envelope guarantees
- `query` echoes every parameter that affected the result, including the **resolved** `log_dir` after `--log-dir` is applied. Agents can confirm scope without re-running the command.
- `count == records.length`.
- `records` is sorted **newest first** for `last`, `tail`, `range`, and `unread`.
- Field names in `records[*]` match exactly what `aalog fields` prints. Adding new fields is non-breaking; renaming or removing them is.
## Tips for LLM use
- Cap aggressively. `--minutes 60 --max 200 --level Error` is more useful than dumping a thousand lines of `Trace`.
- Filter on `Component` first when chasing a specific subsystem (e.g. `aaEngine`, `Bootstrap`, `aaGR`). Levels alone are noisy.
- For follow-along debugging, prefer `unread --client-id <agent-name>` so each agent has its own cache.
- Pair with `graccesscli` mutations: run a `graccess` command, then `aalog tail -m 1 --llm-json` to read the resulting log activity.
+3
View File
@@ -0,0 +1,3 @@
aaLogReader.dll must be dropped into this folder before building.
See ../README.md "Provisioning aaLogReader.dll" for build instructions.
+22
View File
@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2014 Andy Robinson (Phase 2 Automation)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+31
View File
@@ -0,0 +1,31 @@
# aaLogReader build template
Use these three files to rebuild `aaLogReader.dll` from upstream sources without depending on legacy MSBuild + `packages.config`. See [`../../README.md`](../../README.md) "Provisioning aaLogReader.dll" for the full step-by-step.
## Files
- [`aaLogReader.csproj`](aaLogReader.csproj) — SDK-style csproj targeting `net48`, references `Newtonsoft.Json 13.0.3` and `log4net 2.0.15`. Disables determinism (the upstream `AssemblyInfo.cs` uses a wildcarded `[assembly: AssemblyVersion("1.0.*")]`) and pulls in upstream source via relative `<Compile Include="..\aaLog\aaLogReader\..." />` globs. Two source files are excluded from those globs and replaced by the patched copies below.
- [`patched/LogRecord.cs`](patched/LogRecord.cs) — upstream `aaLogReader/Types/LogRecord.cs` with `using System.Runtime.CompilerServices;` added.
- [`patched/LogHeader.cs`](patched/LogHeader.cs) — same patch applied to `aaLogReader/Types/LogHeader.cs`.
## Why the patches
The upstream files reference `[CallerMemberName]` inside an `#if NET45_OR_GREATER` branch but never `using System.Runtime.CompilerServices;`. The original csproj only defined `NET45_OR_GREATER` for `TargetFrameworkVersion >= 4.5`; targeting net40 left the branch dead and the compiler never tripped. When SDK-style projects target net48, the compiler reaches that branch and fails because the attribute can't be resolved. The patched copies add the missing using directive and otherwise match upstream byte-for-byte.
## Layout the recipe expects
```
$env:TEMP\
aaLog\ (git clone https://github.com/aaOpenSource/aaLog.git)
aaLogReader\
aaLgxReader.cs
aaLogReader.cs
Enum\, Helpers\, Properties\, Struct\, Types\
aaLogReader-build\
aaLogReader.csproj (copied from this folder)
patched\
LogRecord.cs (copied from this folder)
LogHeader.cs (copied from this folder)
```
`dotnet build -c Release` from `aaLogReader-build/` produces `bin/Release/net48/aaLogReader.dll`. Copy that into `aalogcli/lib/aaLogReader.dll`.
+38
View File
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<RootNamespace>aaLogReader</RootNamespace>
<AssemblyName>aaLogReader</AssemblyName>
<LangVersion>latest</LangVersion>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<Deterministic>false</Deterministic>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="log4net" Version="2.0.15" />
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.ComponentModel.Composition" />
<Reference Include="System.ComponentModel.DataAnnotations" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\aaLog\aaLogReader\aaLgxReader.cs" />
<Compile Include="..\aaLog\aaLogReader\aaLogReader.cs" />
<Compile Include="..\aaLog\aaLogReader\Enum\*.cs" />
<Compile Include="..\aaLog\aaLogReader\Helpers\*.cs" />
<Compile Include="..\aaLog\aaLogReader\Properties\AssemblyInfo.cs" />
<Compile Include="..\aaLog\aaLogReader\Struct\*.cs" />
<Compile Include="..\aaLog\aaLogReader\Types\ILogHeader.cs" />
<Compile Include="..\aaLog\aaLogReader\Types\ILogRecord.cs" />
<Compile Include="..\aaLog\aaLogReader\Types\aaLogReaderException.cs" />
<Compile Include="patched\LogRecord.cs" />
<Compile Include="patched\LogHeader.cs" />
</ItemGroup>
</Project>
+259
View File
@@ -0,0 +1,259 @@
using System;
using Newtonsoft.Json;
using System.Text;
using System.Runtime.CompilerServices;
namespace aaLogReader
{
public class LogHeader : ILogHeader
{
private static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
public string LogFilePath { get; set; }
public ulong StartMsgNumber { get; set; }
public ulong MsgCount { get; set; }
public ulong EndMsgNumber
{
get
{
return (ulong)(checked(this.StartMsgNumber + this.MsgCount) - 1);
}
}
private ulong _startFileTime;
private DateTimeOffset _startDateTime;
public ulong StartFileTime
{
get { return _startFileTime; }
set
{
_startFileTime = value;
_startDateTime = DateTimeOffset.FromFileTime((long)value);
}
}
public DateTimeOffset StartDateTime
{
get { return _startDateTime; }
}
[JsonIgnore]
public DateTime StartDateTimeLocal
{
get { return _startDateTime.LocalDateTime; }
}
[JsonIgnore]
public DateTime StartDateTimeUtc
{
get { return _startDateTime.UtcDateTime; }
}
private ulong _endFileTime;
private DateTimeOffset _endDateTime;
public ulong EndFileTime
{
get { return _endFileTime; }
set
{
_endFileTime = value;
_endDateTime = DateTimeOffset.FromFileTime((long)value);
}
}
public DateTimeOffset EndDateTime
{
get { return _endDateTime; }
}
[JsonIgnore]
public DateTime EndDateTimeLocal
{
get { return _endDateTime.LocalDateTime; }
}
[JsonIgnore]
public DateTime EndDateTimeUtc
{
get { return _endDateTime.UtcDateTime; }
}
public int OffsetFirstRecord { get; set; }
public int OffsetLastRecord { get; set; }
public string ComputerName { get; set; }
public string Session { get; set; }
public string PrevFileName { get; set; }
public string HostFQDN { get; set; }
[JsonIgnore]
public ReturnCodeStruct ReturnCode { get; set; }
public string ToJSON()
{
return JsonConvert.SerializeObject(this);
}
/// <summary>
/// Return the log header data in the form of a Key-Value Pair
/// </summary>
/// <param name="format">Full or Minimal</param>
/// <returns></returns>
public string ToKVP()
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
localSB.AppendFormat("MsgStartingNumber=\"{0}\"", this.StartMsgNumber.ToString("yyyy-MM-dd HH:mm:ss.fff"));
localSB.AppendFormat(", MsgCount=\"{0}\"", this.MsgCount);
localSB.AppendFormat(", MsgLastNumber=\"{0}\"", this.EndMsgNumber);
localSB.AppendFormat(", StartDateTime=\"{0}\"", this.StartDateTime);
localSB.AppendFormat(", EndDateTime=\"{0}\"", this.EndDateTime);
localSB.AppendFormat(", OffsetFirstRecord=\"{0}\"", this.OffsetFirstRecord);
localSB.AppendFormat(", OffsetLastRecord=\"{0}\"", this.OffsetLastRecord);
localSB.AppendFormat(", ComputerName=\"{0}\"", this.ComputerName);
localSB.AppendFormat(", Session=\"{0}\"", this.Session);
localSB.AppendFormat(", PrevFileName=\"{0}\"", this.PrevFileName);
localSB.AppendFormat(", HostFQDN=\"{0}\"", this.HostFQDN);
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
/// <summary>
/// Get a header for a series of log records with a delimiter
/// </summary>
/// <param name="Delimiter"></param>
/// <param name="format"></param>
/// <returns></returns>
private string localHeader(char Delimiter = ',')
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
localSB.Append("LogFilePath");
localSB.Append(Delimiter + "MsgStartingNumber");
localSB.Append(Delimiter + "MsgCount");
localSB.Append(Delimiter + "MsgLastNumber");
localSB.Append(Delimiter + "StartDateTime");
localSB.Append(Delimiter + "StartFileTime");
localSB.Append(Delimiter + "EndDateTime");
localSB.Append(Delimiter + "EndFileTime");
localSB.Append(Delimiter + "OffsetFirstRecord");
localSB.Append(Delimiter + "OffsetLastRecord");
localSB.Append(Delimiter + "ComputerName");
localSB.Append(Delimiter + "Session");
localSB.Append(Delimiter + "PrevFileName");
localSB.Append(Delimiter + "HostFQDN");
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
public static string Header(char Delimiter = ',')
{
LogHeader lh = new LogHeader();
return lh.localHeader(Delimiter);
}
public static string HeaderCSV()
{
return LogHeader.Header(',');
}
public static string HeaderTSV()
{
return LogHeader.Header('\t');
}
/// <summary>
/// Get the lastRecordRead in the form of a delimited string
/// </summary>
/// <param name="Delimiter">Delimiter to Use</param>
/// <param name="format">Full or Minimal</param>
/// <returns></returns>
public string ToDelimitedString(char Delimiter = ',')
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
localSB.Append("\"" + this.LogFilePath + "\"");
localSB.Append(Delimiter + this.StartMsgNumber.ToString());
localSB.Append(Delimiter + this.MsgCount.ToString());
localSB.Append(Delimiter + this.EndMsgNumber.ToString());
localSB.Append(Delimiter + "\"" + this.StartDateTime.ToString("yyyy-MM-dd HH:mm:ss.fff") + "\"");
localSB.Append(Delimiter + this.StartFileTime.ToString());
localSB.Append(Delimiter + "\"" + this.EndDateTime.ToString("yyyy-MM-dd HH:mm:ss.fff") + "\"");
localSB.Append(Delimiter + this.EndFileTime.ToString());
localSB.Append(Delimiter + this.OffsetFirstRecord.ToString());
localSB.Append(Delimiter + this.OffsetLastRecord.ToString());
localSB.Append(Delimiter + "\"" + this.ComputerName + "\"");
localSB.Append(Delimiter + this.Session);
localSB.Append(Delimiter + "\"" + this.PrevFileName + "\"");
localSB.Append(Delimiter + "\"" + this.HostFQDN + "\"");
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
public string ToCSV()
{
return this.ToDelimitedString(',');
}
public string ToTSV()
{
return this.ToDelimitedString('\t');
}
#if NET45_OR_GREATER
private void LogException(Exception ex, [CallerMemberName]string methodName = "")
{
#else
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private void LogException(Exception ex)
{
string methodName = new System.Diagnostics.StackFrame(1, false).GetMethod().Name;
#endif
log.Error(string.Format("{0}: {1} - {2}", methodName, ex.GetType().Name, ex.Message), ex);
}
}
}
+278
View File
@@ -0,0 +1,278 @@
using System;
using Newtonsoft.Json;
using System.Text;
using System.ComponentModel.DataAnnotations;
using System.Runtime.CompilerServices;
namespace aaLogReader
{
/// <summary>
/// A standard log record
/// </summary>
public class LogRecord : ILogRecord
{
private static readonly log4net.ILog log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
// Default constructor
public LogRecord()
{
this.ReturnCode.Status = false;
this.ReturnCode.Message = "";
}
[JsonIgnore]
public int RecordLength { get; set; }
[JsonIgnore]
public int OffsetToPrevRecord { get; set; }
[JsonIgnore]
public int OffsetToNextRecord { get; set; }
[Key]
public ulong MessageNumber { get; set; }
public uint ProcessID { get; set; }
public uint ThreadID { get; set; }
private ulong _eventFileTime;
private DateTimeOffset _eventDateTime;
public ulong EventFileTime
{
get { return _eventFileTime; }
set
{
_eventFileTime = value;
_eventDateTime = DateTimeOffset.FromFileTime((long)value);
}
}
// TODO: Add UTC Offset for Exact Timestamp
// public int EventUTCOffset;
public DateTimeOffset EventDateTime
{
get { return _eventDateTime; }
}
[JsonIgnore]
public DateTime EventDateTimeLocal
{
get { return _eventDateTime.LocalDateTime; }
}
[JsonIgnore]
public DateTime EventDateTimeUtc
{
get { return _eventDateTime.UtcDateTime; }
}
[JsonIgnore]
public DateTime EventDate
{
get
{
return this.EventDateTime.Date;
}
}
[JsonIgnore]
public string EventTime
{
get { return this.EventDateTime.ToString("hh:mm:ss.fff tt"); }
}
[JsonIgnore]
public int EventMillisec
{
get { return this.EventDateTime.Millisecond; }
}
public string LogFlag { get; set; }
public string Component { get; set; }
public string Message { get; set; }
public string ProcessName { get; set; }
public string SessionID { get; set; }
public string HostFQDN { get; set; }
[JsonIgnore]
public ReturnCodeStruct ReturnCode;
public string ToJSON()
{
return JsonConvert.SerializeObject(this);
}
/// <summary>
/// Return the log record in the form of a Key-Value Pair
/// </summary>
/// <param name="format">Full or Minimal</param>
/// <returns></returns>
public string ToKVP(ExportFormat format = ExportFormat.Full)
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
localSB.AppendFormat("Timestamp=\"{0}\"", this.EventDateTime.ToString("yyyy-MM-dd HH:mm:ss.fff"));
localSB.AppendFormat(", LogFlag=\"{0}\"", this.LogFlag);
localSB.AppendFormat(", Message=\"{0}\"", this.Message);
localSB.AppendFormat(", HostFQDN=\"{0}\"", this.HostFQDN);
if (format == ExportFormat.Full)
{
// Use all parameters if we want a full format
localSB.AppendFormat(", MessageNumber=\"{0}\"", this.MessageNumber);
localSB.AppendFormat(", ProcessID=\"{0}\"", this.ProcessID);
localSB.AppendFormat(", ThreadID=\"{0}\"", this.ThreadID);
localSB.AppendFormat(", Component=\"{0}\"", this.Component);
localSB.AppendFormat(", ProcessName=\"{0}\"", this.ProcessName);
localSB.AppendFormat(", SessionID=\"{0}\"", this.SessionID);
localSB.AppendFormat(", EventFileTime=\"{0}\"", this.EventFileTime);
}
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
/// <summary>
/// Get a header for a series of log records with a delimiter
/// </summary>
/// <param name="Delimiter"></param>
/// <param name="format"></param>
/// <returns></returns>
private string localHeader(char Delimiter = ',', ExportFormat format = ExportFormat.Full)
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
localSB.Append("EventDateTime");
localSB.Append(Delimiter + "LogFlag");
localSB.Append(Delimiter + "Message");
localSB.Append(Delimiter + "HostFQDN");
if (format == ExportFormat.Full)
{
// Use all parameters if we want a full format
localSB.Append(Delimiter + "MessageNumber");
localSB.Append(Delimiter + "ProcessID");
localSB.Append(Delimiter + "ThreadID");
localSB.Append(Delimiter + "Component");
localSB.Append(Delimiter + "ProcessName");
localSB.Append(Delimiter + "SessionID");
localSB.Append(Delimiter + "EventFileTime");
}
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
public static string Header(char Delimiter = ',', ExportFormat format = ExportFormat.Full)
{
LogRecord lr = new LogRecord();
return lr.localHeader(Delimiter, format);
}
public static string HeaderCSV(ExportFormat format = ExportFormat.Full)
{
return LogRecord.Header(',', format);
}
public static string HeaderTSV(ExportFormat format = ExportFormat.Full)
{
return LogRecord.Header('\t', format);
}
/// <summary>
/// Get the log record in the form of a delimited string
/// </summary>
/// <param name="Delimiter">Delimiter to Use</param>
/// <param name="format">Full or Minimal</param>
/// <returns></returns>
public string ToDelimitedString(char Delimiter = ',', ExportFormat format = ExportFormat.Full, DateTimeKind kind = DateTimeKind.Unspecified)
{
string returnValue;
StringBuilder localSB = new StringBuilder();
try
{
if (kind == DateTimeKind.Utc)
localSB.Append("\"" + this.EventDateTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fffZ") + "\"");
else
localSB.Append("\"" + this.EventDateTime.ToString("yyyy-MM-dd HH:mm:ss.fff") + "\"");
localSB.Append(Delimiter + this.LogFlag);
localSB.Append(Delimiter + "\"" + this.Message + "\"");
localSB.Append(Delimiter + this.HostFQDN);
if (format == ExportFormat.Full)
{
// Use all parameters if we want a full format
localSB.Append(Delimiter + this.MessageNumber.ToString());
localSB.Append(Delimiter + this.ProcessID.ToString());
localSB.Append(Delimiter + this.ThreadID.ToString());
localSB.Append(Delimiter + "\"" + this.Component + "\"");
localSB.Append(Delimiter + "\"" + this.ProcessName + "\"");
localSB.Append(Delimiter + this.SessionID);
localSB.Append(Delimiter + this.EventFileTime.ToString());
}
returnValue = localSB.ToString();
}
catch (Exception ex)
{
LogException(ex);
returnValue = "";
}
return returnValue;
}
public string ToCSV(ExportFormat format = ExportFormat.Full)
{
return this.ToDelimitedString(',', format);
}
public string ToTSV(ExportFormat format = ExportFormat.Full)
{
return this.ToDelimitedString('\t', format);
}
#if NET45_OR_GREATER
private void LogException(Exception ex, [CallerMemberName]string methodName = "")
{
#else
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
private void LogException(Exception ex)
{
string methodName = new System.Diagnostics.StackFrame(1, false).GetMethod().Name;
#endif
log.Error(string.Format("{0}: {1} - {2}", methodName, ex.GetType().Name, ex.Message), ex);
}
}
}
+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);
}
}