| | | 1 | | using System.Collections.Concurrent; |
| | | 2 | | |
| | | 3 | | namespace NexusLabs.Needlr.AgentFramework.Workspace; |
| | | 4 | | |
| | | 5 | | /// <summary> |
| | | 6 | | /// Thread-safe in-memory <see cref="IWorkspace"/> backed by a <see cref="ConcurrentDictionary{TKey, TValue}"/>. |
| | | 7 | | /// Suitable for testing, sandboxed agent runs, and scenarios where persistence is not needed. |
| | | 8 | | /// </summary> |
| | | 9 | | /// <remarks> |
| | | 10 | | /// <para> |
| | | 11 | | /// Marked <see cref="DoNotAutoRegisterAttribute"/> to prevent Needlr from registering it as a |
| | | 12 | | /// singleton — workspaces have per-orchestration lifecycle and must be explicitly constructed. |
| | | 13 | | /// </para> |
| | | 14 | | /// <para> |
| | | 15 | | /// Paths are normalized: backslashes are replaced with forward slashes, leading slashes are trimmed, |
| | | 16 | | /// and comparison is case-insensitive (matching Windows file system behavior by default). |
| | | 17 | | /// </para> |
| | | 18 | | /// </remarks> |
| | | 19 | | [DoNotAutoRegister] |
| | | 20 | | public sealed class InMemoryWorkspace : IWorkspace |
| | | 21 | | { |
| | 322 | 22 | | private readonly ConcurrentDictionary<string, string> _files = |
| | 322 | 23 | | new(StringComparer.OrdinalIgnoreCase); |
| | | 24 | | |
| | | 25 | | /// <inheritdoc /> |
| | | 26 | | public WorkspaceResult<ReadFileResult> TryReadFile(string path) |
| | | 27 | | { |
| | 21 | 28 | | var normalized = NormalizePath(path); |
| | 21 | 29 | | return _files.TryGetValue(normalized, out var content) |
| | 21 | 30 | | ? WorkspaceResult<ReadFileResult>.Ok(new ReadFileResult(normalized, content)) |
| | 21 | 31 | | : WorkspaceResult<ReadFileResult>.Fail(new FileNotFoundException($"File not found: {normalized}", normalized |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | /// <inheritdoc /> |
| | | 35 | | public WorkspaceResult<WriteFileResult> TryWriteFile(string path, string content) |
| | | 36 | | { |
| | 125 | 37 | | var normalized = NormalizePath(path); |
| | 125 | 38 | | _files[normalized] = content; |
| | 125 | 39 | | return WorkspaceResult<WriteFileResult>.Ok(new WriteFileResult(normalized, content.Length)); |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | /// <inheritdoc /> |
| | | 43 | | public bool FileExists(string path) => |
| | 39 | 44 | | _files.ContainsKey(NormalizePath(path)); |
| | | 45 | | |
| | | 46 | | /// <inheritdoc /> |
| | | 47 | | public IEnumerable<string> GetFilePaths() => |
| | 3 | 48 | | _files.Keys; |
| | | 49 | | |
| | | 50 | | /// <inheritdoc /> |
| | | 51 | | public ReadOnlyMemory<char> ReadFileAsMemory(string path) |
| | | 52 | | { |
| | 3 | 53 | | var result = TryReadFile(path); |
| | 3 | 54 | | return result.Success |
| | 3 | 55 | | ? result.Value.Content.AsMemory() |
| | 3 | 56 | | : throw result.Exception!; |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | /// <inheritdoc /> |
| | | 60 | | public string ListDirectory(string directory, int maxDepth = 2) |
| | | 61 | | { |
| | 5 | 62 | | var root = NormalizePath(directory).TrimEnd('/'); |
| | 5 | 63 | | var prefix = root.Length > 0 ? root + "/" : ""; |
| | | 64 | | |
| | 5 | 65 | | var entries = _files.Keys |
| | 10 | 66 | | .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || prefix.Length == 0) |
| | 9 | 67 | | .Select(k => prefix.Length > 0 ? k[prefix.Length..] : k) |
| | 9 | 68 | | .OrderBy(k => k, StringComparer.OrdinalIgnoreCase) |
| | 5 | 69 | | .ToList(); |
| | | 70 | | |
| | 5 | 71 | | if (entries.Count == 0) |
| | 1 | 72 | | return root.Length > 0 ? root + "/" : "./"; |
| | | 73 | | |
| | 4 | 74 | | var sb = new System.Text.StringBuilder(); |
| | 4 | 75 | | sb.AppendLine(root.Length > 0 ? root + "/" : "./"); |
| | | 76 | | |
| | 4 | 77 | | var tree = BuildTree(entries); |
| | 4 | 78 | | RenderTree(sb, tree, indent: "", maxDepth: maxDepth, currentDepth: 0); |
| | | 79 | | |
| | 4 | 80 | | return sb.ToString().TrimEnd(); |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | private static SortedDictionary<string, object?> BuildTree(List<string> paths) |
| | | 84 | | { |
| | 4 | 85 | | var root = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase); |
| | 26 | 86 | | foreach (var path in paths) |
| | | 87 | | { |
| | 9 | 88 | | var parts = path.Split('/'); |
| | 9 | 89 | | var current = root; |
| | 52 | 90 | | for (var i = 0; i < parts.Length; i++) |
| | | 91 | | { |
| | 17 | 92 | | var part = parts[i]; |
| | 17 | 93 | | if (i == parts.Length - 1) |
| | | 94 | | { |
| | | 95 | | // File leaf |
| | 9 | 96 | | current.TryAdd(part, null); |
| | | 97 | | } |
| | | 98 | | else |
| | | 99 | | { |
| | | 100 | | // Directory node |
| | 8 | 101 | | if (!current.TryGetValue(part, out var child) || child is not SortedDictionary<string, object?> dict |
| | | 102 | | { |
| | 6 | 103 | | dict = new SortedDictionary<string, object?>(StringComparer.OrdinalIgnoreCase); |
| | 6 | 104 | | current[part] = dict; |
| | | 105 | | } |
| | 8 | 106 | | current = dict; |
| | | 107 | | } |
| | | 108 | | } |
| | | 109 | | } |
| | 4 | 110 | | return root; |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | private static void RenderTree( |
| | | 114 | | System.Text.StringBuilder sb, |
| | | 115 | | SortedDictionary<string, object?> node, |
| | | 116 | | string indent, |
| | | 117 | | int maxDepth, |
| | | 118 | | int currentDepth) |
| | | 119 | | { |
| | 8 | 120 | | if (currentDepth >= maxDepth) |
| | 2 | 121 | | return; |
| | | 122 | | |
| | 6 | 123 | | var entries = node.ToList(); |
| | 32 | 124 | | for (var i = 0; i < entries.Count; i++) |
| | | 125 | | { |
| | 10 | 126 | | var isLast = i == entries.Count - 1; |
| | 10 | 127 | | var connector = isLast ? "└── " : "├── "; |
| | 10 | 128 | | var childIndent = indent + (isLast ? " " : "│ "); |
| | | 129 | | |
| | 10 | 130 | | var (name, child) = entries[i]; |
| | 10 | 131 | | if (child is SortedDictionary<string, object?> dict) |
| | | 132 | | { |
| | 4 | 133 | | sb.AppendLine($"{indent}{connector}{name}/"); |
| | 4 | 134 | | RenderTree(sb, dict, childIndent, maxDepth, currentDepth + 1); |
| | | 135 | | } |
| | | 136 | | else |
| | | 137 | | { |
| | 6 | 138 | | sb.AppendLine($"{indent}{connector}{name}"); |
| | | 139 | | } |
| | | 140 | | } |
| | 6 | 141 | | } |
| | | 142 | | |
| | | 143 | | /// <inheritdoc /> |
| | | 144 | | public WorkspaceResult<CompareExchangeResult> TryCompareExchange(string path, string expectedContent, string newCont |
| | | 145 | | { |
| | 6 | 146 | | var normalized = NormalizePath(path); |
| | | 147 | | |
| | 6 | 148 | | if (!_files.TryGetValue(normalized, out var current)) |
| | 2 | 149 | | return WorkspaceResult<CompareExchangeResult>.Fail( |
| | 2 | 150 | | new FileNotFoundException($"File not found: {normalized}", normalized)); |
| | | 151 | | |
| | 4 | 152 | | if (!string.Equals(current, expectedContent, StringComparison.Ordinal)) |
| | 2 | 153 | | return WorkspaceResult<CompareExchangeResult>.Ok( |
| | 2 | 154 | | new CompareExchangeResult(false, "Content mismatch — file was modified since last read.")); |
| | | 155 | | |
| | 2 | 156 | | var exchanged = _files.TryUpdate(normalized, newContent, current); |
| | 2 | 157 | | return WorkspaceResult<CompareExchangeResult>.Ok( |
| | 2 | 158 | | new CompareExchangeResult(exchanged, exchanged ? null : "Concurrent modification during exchange.")); |
| | | 159 | | } |
| | | 160 | | |
| | | 161 | | /// <summary> |
| | | 162 | | /// Seeds the workspace with a file before agent execution begins. |
| | | 163 | | /// Convenience method for test setup and scenario harnesses. |
| | | 164 | | /// </summary> |
| | | 165 | | public void SeedFile(string path, string content) => |
| | 30 | 166 | | _files[NormalizePath(path)] = content; |
| | | 167 | | |
| | | 168 | | private static string NormalizePath(string path) => |
| | 226 | 169 | | path.Replace('\\', '/').TrimStart('/'); |
| | | 170 | | } |