V10 / 66cebf3528e0
CalendarSession.cs
Lilith/Core/Calendar/CalendarSession.cs
Changed in this versionCS
using System.Globalization;
using System.Text.Json;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;
namespace Lilith.Core.Calendar;
/// <summary>Single-host calendar; detection persists notices without invoking a conversation.</summary>
public sealed class CalendarSession : IDisposable
{
private readonly object _sync = new();
private readonly FileStream _lease;
private readonly string _file;
private readonly TimeProvider _clock;
private CalendarState _state;
private bool _faulted, _disposed;
public CalendarOptions Options { get; }
public CalendarSession(CalendarOptions options, TimeProvider? clock = null)
{
if (!Enum.IsDefined(options.Mode)) throw new ArgumentException("Unknown calendar access mode.");
Options = options with { RootDirectory = Path.GetFullPath(options.RootDirectory) };
Options.RootDirectory.EnsureNoLinks();
Directory.CreateDirectory(Options.RootDirectory);
_clock = clock ?? TimeProvider.System;
_file = Path.Combine(Options.RootDirectory, "calendar.json");
_lease = new(Path.Combine(Options.RootDirectory, "host.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
try
{
_state = File.Exists(_file) ? JsonSerializer.Deserialize<CalendarState>(File.ReadAllText(_file))
?? throw new InvalidDataException("Invalid calendar state.") : new();
if (_state.Entries.Count > 1000 || _state.Pending.Count > 100 || _state.Notices.Count > 1000)
throw new InvalidDataException("Calendar limits exceeded.");
// A crash may have left a queued notice. Retain one missed notice, never run actions.
_state.Notices = _state.Notices.Select(n => n with { Missed = true, Message = MissedMessage(n.Title) }).ToList();
Detect(true);
Save();
}
catch { _lease.Dispose(); throw; }
}
public CalendarResult Execute(CalendarRequest request)
{
lock (_sync)
{
Check();
return request.Operation switch
{
"create" or "update" or "delete" => Prepare(request),
"read" => new("completed", "Calendar event.", Find(request.Id)),
"search" => new("completed", "Calendar matches (up to 20).", _state.Entries
.Where(e => e.Title.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Notes.Contains(request.Query, StringComparison.OrdinalIgnoreCase) || e.Date == request.Query)
.OrderBy(e => e.Date).ThenBy(e => e.Time).Take(20).Select(Summary).ToArray()),
"agenda" => Agenda(),
"pending" => new("completed", "Pending calendar changes.", _state.Pending.ToArray()),
_ => throw new ArgumentException("Unknown calendar operation.")
};
}
}
private CalendarResult Agenda()
{
var now = _clock.GetUtcNow();
var events = _state.Entries.Where(e => e.Date == TimeZoneInfo.ConvertTime(now, Zone(e.TimeZone)).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))
.OrderBy(e => e.Time).ToArray();
var overdue = _state.Notices.Where(n => n.DueUtc <= now).ToArray();
return new("completed", "Today's events and overdue reminders.", new { has_items = events.Length + overdue.Length > 0,
event_count = events.Length, overdue_count = overdue.Length, events = events.Take(20).Select(Summary).ToArray(), overdue = overdue.Take(20).ToArray() });
}
private CalendarResult Prepare(CalendarRequest request)
{
// Persist exact absolute times and stable ID at preparation, never reinterpret on approval.
if (request.Operation == "create" && request.Id.Length == 0) request = request with { Id = Guid.NewGuid().ToString("N") };
CalendarEntry? prepared = Validate(request);
if (prepared is not null) request = request with { TimeZone = prepared.TimeZone };
if (Options.Mode == WorkspaceAccessMode.Yolo)
{
var result = Commit(request); Save(); return result;
}
if (_state.Pending.Count >= 100) throw new InvalidOperationException("Review existing calendar proposals first.");
string id = Guid.NewGuid().ToString("N");
_state.Pending.Add(new(id, request, _state.Entries.SingleOrDefault(e => e.Id == request.Id)?.Revision));
Save();
return new("needs_approval", $"Approve calendar {request.Operation}: {request.Title switch { "" => Find(request.Id).Title, _ => request.Title }}.", request, id);
}
public CalendarProposal Review(string id)
{
lock (_sync) { Check(); return _state.Pending.SingleOrDefault(p => p.Id == id) ?? throw new ArgumentException("Unknown calendar proposal."); }
}
public CalendarResult Approve(string id)
{
lock (_sync)
{
var proposal = Review(id);
if (_state.Entries.SingleOrDefault(e => e.Id == proposal.Request.Id)?.Revision != proposal.ExpectedRevision)
throw new InvalidOperationException("Calendar event changed. Reject this proposal and prepare another.");
Validate(proposal.Request);
var result = Commit(proposal.Request);
_state.Pending.Remove(proposal); Save(); return result;
}
}
public void Reject(string id) { lock (_sync) { var proposal = Review(id); _state.Pending.Remove(proposal); Save(); } }
private CalendarEntry? Validate(CalendarRequest request)
{
if (request.Id.Length is < 1 or > 64 || request.Id.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('_' or '-')))
throw new ArgumentException("Event ID must contain 1-64 letters, digits, underscores or hyphens.");
if (request.Operation == "create")
{
if (_state.Entries.Count >= 1000 || _state.Entries.Any(e => e.Id == request.Id)) throw new ArgumentException("Calendar ID exists or event limit reached.");
}
else
{
_ = Find(request.Id);
if (_state.Notices.Any(n => n.EventId == request.Id)) throw new InvalidOperationException("Deliver this event's outstanding reminder before changing it.");
}
if (request.Operation == "delete") return null;
if (request.Title.Length is < 1 or > 200 || string.IsNullOrWhiteSpace(request.Title) || request.Title.Any(char.IsControl) || request.Notes.Length > 2000 || request.Source.Length > 2000)
throw new ArgumentException("Provide a title (1-200 visible characters), notes/source up to 2000 characters.");
if (request.Source.Length > 0 && (!Uri.TryCreate(request.Source, UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http")))
throw new ArgumentException("Source must be an HTTP(S) URL.");
if (!DateOnly.TryParseExact(request.Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) throw new ArgumentException("Date must be yyyy-MM-dd.");
if (_state.Entries.Any(e => e.Id != request.Id && e.Title.Equals(request.Title, StringComparison.OrdinalIgnoreCase) && e.Date == request.Date && e.Time == request.Time))
throw new ArgumentException("This title/date/time is already in the calendar. Search and update the existing event instead.");
var zone = Zone(request.TimeZone.Length == 0 ? TimeZoneInfo.Local.Id : request.TimeZone);
DateTimeOffset? starts = request.Time.Length == 0 ? null : At(date, request.Time, zone);
DateTimeOffset? reminder = request.Reminder switch
{
"none" => null,
"" => throw new ArgumentException("Ask the user for a reminder time, or explicit none. Do not choose a default."),
_ => ParseReminder(request.Reminder, zone)
};
if (reminder <= _clock.GetUtcNow()) throw new ArgumentException("Reminder must be in the future; ask for a new reminder time or none.");
return new(request.Id, request.Title, request.Date, request.Time, zone.Id, starts, reminder, request.Notes, request.Source,
request.Operation == "create" ? 1 : Find(request.Id).Revision + 1);
}
private static TimeZoneInfo Zone(string id) => TimeZoneInfo.FindSystemTimeZoneById(id);
private static DateTimeOffset At(DateOnly date, string time, TimeZoneInfo zone)
{
if (!TimeOnly.TryParseExact(time, "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) throw new ArgumentException("Time must be HH:mm.");
var local = date.ToDateTime(parsed, DateTimeKind.Unspecified);
if (zone.IsInvalidTime(local) || zone.IsAmbiguousTime(local)) throw new ArgumentException("Local time is invalid or ambiguous at a daylight-saving change. Choose an unambiguous local time.");
return new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, zone), TimeSpan.Zero);
}
private static DateTimeOffset ParseReminder(string value, TimeZoneInfo zone)
{
if (value.Length != 16 || value[10] != 'T' || !DateOnly.TryParseExact(value[..10], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
throw new ArgumentException("Reminder must be yyyy-MM-ddTHH:mm in the event time zone, or none.");
return At(date, value[11..], zone);
}
private CalendarResult Commit(CalendarRequest request)
{
var entry = Validate(request);
string title = entry?.Title ?? Find(request.Id).Title;
_state.Entries.RemoveAll(e => e.Id == request.Id);
if (entry is not null) _state.Entries.Add(entry);
return new("completed", $"Calendar {request.Operation} completed{(entry is null ? ": " + title : "")}.", entry);
}
public void Tick() { lock (_sync) { Check(); if (Detect(false)) Save(); } }
private bool Detect(bool missed)
{
var due = _state.Entries.Where(e => !e.ReminderQueued && e.ReminderUtc <= _clock.GetUtcNow()).ToArray();
foreach (var entry in due)
{
_state.Entries[_state.Entries.IndexOf(entry)] = entry with { ReminderQueued = true, Revision = entry.Revision + 1 };
_state.Notices.Add(new(Guid.NewGuid().ToString("N"), entry.Id, entry.Title, entry.ReminderUtc!.Value, missed,
missed ? MissedMessage(entry.Title) : $"Calendar reminder: {entry.Title}, {entry.Date}{(entry.Time.Length == 0 ? " (all day)" : " at " + entry.Time + " " + entry.TimeZone)}."));
}
return due.Length > 0;
}
private static string MissedMessage(string title) => $"Missed calendar reminder while Lilith was closed: {title}. No action was replayed.";
public CalendarNotice? PeekNotice() { lock (_sync) { Check(); return _state.Notices.FirstOrDefault(); } }
public void CompleteNotice(string id) { lock (_sync) { Check(); _state.Notices.RemoveAll(n => n.Id == id); Save(); } }
private CalendarEntry Find(string id) => _state.Entries.SingleOrDefault(e => e.Id == id) ?? throw new ArgumentException("Unknown calendar event ID. Search the calendar first.");
private static object Summary(CalendarEntry entry) => new { entry.Id, entry.Title, entry.Date, entry.Time, entry.TimeZone, entry.ReminderUtc };
private void Save()
{
string temp = _file + "." + Guid.NewGuid().ToString("N") + ".tmp";
try { File.WriteAllText(temp, JsonSerializer.Serialize(_state)); File.Move(temp, _file, true); }
catch { _faulted = true; throw; }
}
private void Check() { ObjectDisposedException.ThrowIf(_disposed, this); if (_faulted) throw new IOException("Calendar storage failed. Fix storage and restart."); }
public void Dispose() { lock (_sync) { if (_disposed) return; _disposed = true; _lease.Dispose(); } }
}