summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Recovery/TaskResourceJournal.cs
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-09 21:08:20 +0900
commitb07b41cd435991cd254957af79055080c85b346e (patch)
tree27720d3ce14e4d0b84670a55ac26a2f877ce3a20 /src/Agent.Windows/Recovery/TaskResourceJournal.cs
parent2fefd29e86c3b926d0cf4a362251d3f119688612 (diff)
feat: AI Agent Sandbox MCP Server v1を実装
Specifications、Design、ImplementationPlanの優先順位を整理し、単一Task実行モデル、typed option優先、設定可能なresource上限、local Ollamaを必須とするRelease Gateを文書化。 .NET 10/C# 14の5 production project、3 test project、E2E Clientを構築。typed YAML、en/ja locale、fixed/user prompt、stdioおよびloopback限定Streamable HTTP、agent_run/agent_status、Session、Ollama text/tool/Vision、TaskGraphとtoken accountingを実装。 Windows sandboxとしてfixed AppContainer、canonical/reparse検査、能力別ACL、path+SHA-256 executable approval、child-process restriction、Job Object、task journal、atomic file transaction、crash recoveryを実装。WFPのIPv4/IPv6 default denyとexact allow、dynamic session、temporary loopback exemption、管理者/BFE診断を追加。 Security/Recovery test、MCP/Ollama integration test、stdio/HTTP Operational E2E、Release Bundle生成・checksum・manifest・fresh extraction検査を追加。Requirement TraceabilityとPhase 0〜11の進捗、初回セットアップ可能なREADMEを整備。 Debug/Release/publish/objをrepository rootのbuild/へ集約し、旧project-local bin/objへの依存を除去。artifacts/とbuild/をGit管理外に設定し、clean checkoutでもsolution buildからE2E Clientまで生成可能にした。
Diffstat (limited to 'src/Agent.Windows/Recovery/TaskResourceJournal.cs')
-rw-r--r--src/Agent.Windows/Recovery/TaskResourceJournal.cs108
1 files changed, 108 insertions, 0 deletions
diff --git a/src/Agent.Windows/Recovery/TaskResourceJournal.cs b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
new file mode 100644
index 0000000..b638041
--- /dev/null
+++ b/src/Agent.Windows/Recovery/TaskResourceJournal.cs
@@ -0,0 +1,108 @@
+using System.Text.Json;
+
+namespace Agent.Windows.Recovery;
+
+public enum JournalEntryState { Prepared, Applied, Cleaned }
+
+public sealed class JournalEntry
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Kind { get; set; } = string.Empty;
+ public string Target { get; set; } = string.Empty;
+ public JournalEntryState State { get; set; }
+ public Dictionary<string, string> Data { get; set; } = new(StringComparer.Ordinal);
+}
+
+public sealed class JournalDocument
+{
+ public int SchemaVersion { get; set; } = 1;
+ public Guid TaskId { get; set; }
+ public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
+ public List<JournalEntry> Entries { get; set; } = [];
+}
+
+public sealed class TaskResourceJournal
+{
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+ private readonly object _sync = new();
+ private readonly JournalDocument _document;
+
+ private TaskResourceJournal(string path, JournalDocument document) { Path = path; _document = document; }
+ public string Path { get; }
+ public Guid TaskId => _document.TaskId;
+ public IReadOnlyList<JournalEntry> Entries { get { lock (_sync) return _document.Entries.Select(Clone).ToArray(); } }
+
+ public static TaskResourceJournal Create(string journalDirectory, Guid taskId)
+ {
+ Directory.CreateDirectory(journalDirectory);
+ string path = System.IO.Path.Combine(journalDirectory, taskId.ToString("N") + ".journal.json");
+ if (File.Exists(path)) throw new InvalidOperationException($"Journal already exists for task '{taskId}'.");
+ var journal = new TaskResourceJournal(path, new JournalDocument { TaskId = taskId });
+ journal.Persist();
+ return journal;
+ }
+
+ public static TaskResourceJournal Open(string path)
+ {
+ JournalDocument document = JsonSerializer.Deserialize<JournalDocument>(File.ReadAllText(path), JsonOptions)
+ ?? throw new InvalidDataException($"Journal '{path}' is empty.");
+ if (document.SchemaVersion != 1 || document.TaskId == Guid.Empty) throw new InvalidDataException($"Journal '{path}' is invalid.");
+ return new(path, document);
+ }
+
+ public Guid Prepare(string kind, string target, IReadOnlyDictionary<string, string> data)
+ {
+ lock (_sync)
+ {
+ var entry = new JournalEntry { Kind = kind, Target = target, State = JournalEntryState.Prepared, Data = new(data, StringComparer.Ordinal) };
+ _document.Entries.Add(entry);
+ Persist();
+ return entry.Id;
+ }
+ }
+
+ public void MarkApplied(Guid entryId, IReadOnlyDictionary<string, string>? additionalData = null)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State != JournalEntryState.Prepared) throw new InvalidOperationException("Only prepared resources can be marked applied.");
+ if (additionalData is not null) foreach ((string key, string value) in additionalData) entry.Data[key] = value;
+ entry.State = JournalEntryState.Applied;
+ Persist();
+ }
+ }
+
+ public void MarkCleaned(Guid entryId)
+ {
+ lock (_sync)
+ {
+ JournalEntry entry = Get(entryId);
+ if (entry.State == JournalEntryState.Cleaned) return;
+ entry.State = JournalEntryState.Cleaned;
+ Persist();
+ }
+ }
+
+ public void CompleteAndDelete()
+ {
+ lock (_sync)
+ {
+ if (_document.Entries.Any(entry => entry.State != JournalEntryState.Cleaned)) throw new InvalidOperationException("Journal still contains live resources.");
+ File.Delete(Path);
+ }
+ }
+
+ private JournalEntry Get(Guid id) => _document.Entries.SingleOrDefault(entry => entry.Id == id) ?? throw new KeyNotFoundException($"Journal entry '{id}' not found.");
+ private void Persist()
+ {
+ string temporary = Path + ".tmp";
+ using (FileStream stream = new(temporary, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
+ {
+ JsonSerializer.Serialize(stream, _document, JsonOptions);
+ stream.Flush(flushToDisk: true);
+ }
+ File.Move(temporary, Path, overwrite: true);
+ }
+ private static JournalEntry Clone(JournalEntry source) => new() { Id = source.Id, Kind = source.Kind, Target = source.Target, State = source.State, Data = new(source.Data, StringComparer.Ordinal) };
+}