-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFileCheckpointStore.cs
More file actions
165 lines (142 loc) · 6.74 KB
/
Copy pathFileCheckpointStore.cs
File metadata and controls
165 lines (142 loc) · 6.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
using System.Text.Json;
using System.Text.RegularExpressions;
using GsdOrchestrator.Workflows.Models;
using Microsoft.Extensions.Logging;
namespace GsdOrchestrator.Checkpointing;
public interface ICheckpointStore
{
Task SaveAsync(GsdWorkflowContext ctx, CancellationToken ct = default);
Task<GsdWorkflowContext?> LoadAsync(string workflowId, CancellationToken ct = default);
Task<IReadOnlyList<string>> ListActiveWorkflowsAsync(CancellationToken ct = default);
Task ArchiveAsync(string workflowId, CancellationToken ct = default);
}
/// <summary>
/// Persists GsdWorkflowContext as JSON files under .gsd/state/{workflowId}.json.
/// Uses atomic write (temp file + rename) to prevent corrupt checkpoints.
/// </summary>
public sealed class FileCheckpointStore : ICheckpointStore
{
private readonly string _stateDir;
private readonly string _archiveDir;
private readonly ILogger<FileCheckpointStore> _logger;
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
/// <summary>
/// CR-02/T-16-05: Allowlist-based sanitizer — only alphanumerics, hyphens, and underscores.
/// Replaces every other character with '_' to prevent path traversal.
/// </summary>
private static readonly Regex SafeSegment =
new(@"[^a-zA-Z0-9\-_]", RegexOptions.Compiled);
private static string Sanitize(string segment) =>
SafeSegment.Replace(segment, "_");
public FileCheckpointStore(string repoRoot, ILogger<FileCheckpointStore> logger)
{
_stateDir = Path.Combine(repoRoot, ".gsd", "state");
_archiveDir = Path.GetFullPath(Path.Combine(repoRoot, ".gsd", "archive"));
_logger = logger;
Directory.CreateDirectory(_stateDir);
}
public async Task SaveAsync(GsdWorkflowContext ctx, CancellationToken ct = default)
{
var path = StatePath(ctx.Issue?.RepoOwner ?? "", ctx.Issue?.RepoName ?? "", ctx.WorkflowId);
var tmp = path + ".tmp";
await using (var fs = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
{
await JsonSerializer.SerializeAsync(fs, ctx, JsonOpts, ct);
fs.Flush(flushToDisk: true);
}
// Atomic rename — prevents partial writes leaving corrupt checkpoints
File.Move(tmp, path, overwrite: true);
_logger.LogDebug("Checkpoint saved: {WorkflowId} → {State}", ctx.WorkflowId, ctx.CurrentState);
}
private const string CurrentSchemaVersion = "1.1";
private const string LegacySchemaVersion = "1.0";
/// <summary>
/// CR-03: Try exact (legacy) path first, then scan for namespaced *_{workflowId}.json.
/// This allows resuming workflows saved after Phase 16 namespacing was introduced.
/// Phase 18: Validates SchemaVersion — returns null and logs a warning on mismatch.
/// </summary>
public async Task<GsdWorkflowContext?> LoadAsync(string workflowId, CancellationToken ct = default)
{
// Try exact match first (legacy / no-owner-repo path)
var exactPath = StatePath(workflowId);
if (File.Exists(exactPath))
{
await using var fs = new FileStream(exactPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var ctx = await JsonSerializer.DeserializeAsync<GsdWorkflowContext>(fs, JsonOpts, ct);
return ValidateSchemaVersion(ctx, workflowId);
}
// Try namespaced match: *_{sanitizedWorkflowId}.json
var sanitized = Sanitize(workflowId);
var candidates = Directory.GetFiles(_stateDir, $"*_{sanitized}.json");
if (candidates.Length == 1)
{
await using var fs2 = new FileStream(candidates[0], FileMode.Open, FileAccess.Read, FileShare.Read);
var ctx2 = await JsonSerializer.DeserializeAsync<GsdWorkflowContext>(fs2, JsonOpts, ct);
return ValidateSchemaVersion(ctx2, workflowId);
}
return null;
}
private GsdWorkflowContext? ValidateSchemaVersion(GsdWorkflowContext? ctx, string workflowId)
{
if (ctx is null) return null;
if (ctx.SchemaVersion == CurrentSchemaVersion)
return ctx;
if (ctx.SchemaVersion == LegacySchemaVersion)
{
_logger.LogInformation(
"Upgrading checkpoint {WorkflowId} from schema {Previous} to {Current}.",
workflowId, LegacySchemaVersion, CurrentSchemaVersion);
return ctx with { SchemaVersion = CurrentSchemaVersion };
}
throw new InvalidDataException(
$"Checkpoint '{workflowId}' uses unsupported schema version '{ctx.SchemaVersion}'. Expected '{CurrentSchemaVersion}' or legacy '{LegacySchemaVersion}'.");
}
public Task<IReadOnlyList<string>> ListActiveWorkflowsAsync(CancellationToken ct = default)
{
var ids = Directory.EnumerateFiles(_stateDir, "*.json")
.Where(f => !f.EndsWith(".failed.json", StringComparison.OrdinalIgnoreCase))
.Select(Path.GetFileNameWithoutExtension)
.OfType<string>()
.ToList();
return Task.FromResult<IReadOnlyList<string>>(ids);
}
/// <summary>
/// CR-03: Try exact path first, then scan for namespaced *_{workflowId}.json.
/// WR-03: Uses _archiveDir computed once in the constructor.
/// </summary>
public Task ArchiveAsync(string workflowId, CancellationToken ct = default)
{
// Try exact match first
var src = StatePath(workflowId);
if (!File.Exists(src))
{
// Try namespaced match
var sanitized = Sanitize(workflowId);
var candidates = Directory.GetFiles(_stateDir, $"*_{sanitized}.json");
if (candidates.Length == 1)
src = candidates[0];
else
return Task.CompletedTask;
}
Directory.CreateDirectory(_archiveDir);
var destName = Path.GetFileName(src);
File.Move(src, Path.Combine(_archiveDir, destName), overwrite: true);
return Task.CompletedTask;
}
/// <summary>CR-01: Sanitize workflowId to prevent path traversal.</summary>
private string StatePath(string workflowId) =>
Path.Combine(_stateDir, $"{Sanitize(workflowId)}.json");
/// <summary>Per-repo namespaced path — MULTI-03. New saves include owner+repo prefix.</summary>
private string StatePath(string owner, string repo, string workflowId)
{
var prefix = (string.IsNullOrEmpty(owner) || string.IsNullOrEmpty(repo))
? Sanitize(workflowId)
: $"{Sanitize(owner)}_{Sanitize(repo)}_{Sanitize(workflowId)}";
return Path.Combine(_stateDir, $"{prefix}.json");
}
}