Forge
csharpdeeb25a2
1#nullable enable
2using System.Text.Json;
3using CascadeIDE.Models.AgentChat;
4
5namespace CascadeIDE.Features.Chat;
6
7/// <summary>Детерминированная проекция SEDM-событий из append-only log (ADR 0173/0174 MLP).</summary>
8public static class SedmEventProjector
9{
10 public sealed record DecisionState(
11 Guid EventId,
12 SedmDecisionRecordedPayload Payload,
13 string Status);
14
15 public sealed record WorklineProjection(
16 string WorklineId,
17 SedmContextCardMaterializedPayload? ContextCard,
18 SedmIntentCardRecordedPayload? IntentCard,
19 DecisionState? ActiveDecision,
20 IReadOnlyList<DecisionState> DecisionHistory);
21
22 public sealed record SessionProjection(
23 IReadOnlyDictionary<string, WorklineProjection> ByWorkline,
24 int OpenWorklineCount);
25
26 public static SessionProjection Project(
27 IReadOnlyList<ChatHistoryEvent> events,
28 Guid activeWorklineId,
29 IReadOnlyDictionary<Guid, string>? threadTitles = null,
30 int openWorklineCount = 1)
31 {
32 var worklines = new Dictionary<string, MutableWorkline>(StringComparer.OrdinalIgnoreCase);
33 var decisions = new Dictionary<string, DecisionState>(StringComparer.OrdinalIgnoreCase);
34
35 foreach (var ev in events)
36 {
37 switch (ev.Kind)
38 {
39 case ChatHistoryEventKind.ContextCardMaterialized:
40 if (TryDeserialize<SedmContextCardMaterializedPayload>(ev.PayloadJson, out var ctx))
41 GetOrAdd(worklines, ctx.WorklineId).ContextCard = ctx;
42 break;
43
44 case ChatHistoryEventKind.IntentCardRecorded:
45 if (TryDeserialize<SedmIntentCardRecordedPayload>(ev.PayloadJson, out var intent))
46 GetOrAdd(worklines, intent.WorklineId).IntentCard = intent;
47 break;
48
49 case ChatHistoryEventKind.DecisionRecorded:
50 if (TryDeserialize<SedmDecisionRecordedPayload>(ev.PayloadJson, out var decision))
51 {
52 var state = new DecisionState(ev.EventId, decision, NormalizeStatus(decision.Status));
53 decisions[ev.EventId.ToString("N")] = state;
54 GetOrAdd(worklines, decision.WorklineId).DecisionHistory.Add(state);
55 }
56 break;
57
58 case ChatHistoryEventKind.DecisionMarkedStale:
59 if (TryDeserialize<SedmDecisionLifecyclePayload>(ev.PayloadJson, out var stale)
60 && decisions.TryGetValue(stale.DecisionEventId, out var staleTarget))
61 {
62 decisions[stale.DecisionEventId] = staleTarget with { Status = "stale" };
63 ReplaceInHistory(worklines, stale.DecisionEventId, "stale");
64 }
65 break;
66
67 case ChatHistoryEventKind.DecisionSuperseded:
68 if (TryDeserialize<SedmDecisionLifecyclePayload>(ev.PayloadJson, out var superseded)
69 && decisions.TryGetValue(superseded.DecisionEventId, out var supersededTarget))
70 {
71 decisions[superseded.DecisionEventId] = supersededTarget with { Status = "superseded" };
72 ReplaceInHistory(worklines, superseded.DecisionEventId, "superseded");
73 }
74 break;
75 }
76 }
77
78 var byWorkline = worklines.ToDictionary(
79 static kv => kv.Key,
80 static kv => ToProjection(kv.Value),
81 StringComparer.OrdinalIgnoreCase);
82
83 if (activeWorklineId != Guid.Empty)
84 {
85 var activeKey = activeWorklineId.ToString("N");
86 if (!byWorkline.ContainsKey(activeKey))
87 {
88 var label = threadTitles is not null && threadTitles.TryGetValue(activeWorklineId, out var title)
89 ? title
90 : null;
91 byWorkline[activeKey] = new WorklineProjection(
92 activeKey,
93 null,
94 null,
95 null,
96 []);
97 }
98 }
99
100 return new SessionProjection(byWorkline, Math.Max(1, openWorklineCount));
101 }
102
103 public static WorklineProjection ResolveWorkline(SessionProjection projection, Guid worklineId)
104 {
105 if (worklineId == Guid.Empty)
106 return EmptyWorkline("");
107
108 var key = worklineId.ToString("N");
109 return projection.ByWorkline.TryGetValue(key, out var wl)
110 ? wl
111 : EmptyWorkline(key);
112 }
113
114 public static bool IsSameContextCard(SedmContextCardMaterializedPayload? left, SedmContextCardMaterializedPayload right) =>
115 left is not null
116 && string.Equals(left.WorklineId, right.WorklineId, StringComparison.OrdinalIgnoreCase)
117 && string.Equals(left.Anchor.Path, right.Anchor.Path, StringComparison.OrdinalIgnoreCase)
118 && string.Equals(left.Anchor.Symbol, right.Anchor.Symbol, StringComparison.OrdinalIgnoreCase);
119
120 private static WorklineProjection ToProjection(MutableWorkline state)
121 {
122 DecisionState? active = null;
123 for (var i = state.DecisionHistory.Count - 1; i >= 0; i--)
124 {
125 var candidate = state.DecisionHistory[i];
126 if (string.Equals(candidate.Status, "active", StringComparison.OrdinalIgnoreCase))
127 {
128 active = candidate;
129 break;
130 }
131 }
132
133 return new WorklineProjection(
134 state.WorklineId,
135 state.ContextCard,
136 state.IntentCard,
137 active,
138 state.DecisionHistory);
139 }
140
141 private static WorklineProjection EmptyWorkline(string worklineId) =>
142 new(worklineId, null, null, null, []);
143
144 private static MutableWorkline GetOrAdd(Dictionary<string, MutableWorkline> worklines, string worklineId)
145 {
146 if (!worklines.TryGetValue(worklineId, out var wl))
147 {
148 wl = new MutableWorkline(worklineId);
149 worklines[worklineId] = wl;
150 }
151
152 return wl;
153 }
154
155 private static void ReplaceInHistory(
156 Dictionary<string, MutableWorkline> worklines,
157 string decisionEventId,
158 string status)
159 {
160 foreach (var wl in worklines.Values)
161 {
162 for (var i = 0; i < wl.DecisionHistory.Count; i++)
163 {
164 if (!string.Equals(wl.DecisionHistory[i].EventId.ToString("N"), decisionEventId, StringComparison.OrdinalIgnoreCase))
165 continue;
166 wl.DecisionHistory[i] = wl.DecisionHistory[i] with { Status = status };
167 }
168 }
169 }
170
171 private static string NormalizeStatus(string? status) =>
172 string.IsNullOrWhiteSpace(status) ? "active" : status.Trim().ToLowerInvariant();
173
174 private static bool TryDeserialize<T>(string payloadJson, out T value)
175 {
176 value = default!;
177 try
178 {
179 var parsed = JsonSerializer.Deserialize<T>(payloadJson, ChatHistoryJson.Options);
180 if (parsed is null)
181 return false;
182 value = parsed;
183 return true;
184 }
185 catch (JsonException)
186 {
187 return false;
188 }
189 }
190
191 private sealed class MutableWorkline(string worklineId)
192 {
193 public string WorklineId { get; } = worklineId;
194 public SedmContextCardMaterializedPayload? ContextCard { get; set; }
195 public SedmIntentCardRecordedPayload? IntentCard { get; set; }
196 public List<DecisionState> DecisionHistory { get; } = [];
197 }
198}
199
View only · write via MCP/CIDE