| 1 | package transcript |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/event" |
| 8 | "reasonix/internal/provider" |
| 9 | ) |
| 10 | |
| 11 | // Terminal records belong to the authoritative snapshot, not only the live |
| 12 | // frontend. Advancing coverage without them would permanently hide recovery |
| 13 | // actions on reconnect, including after the WAL has been compacted. |
| 14 | func (p *Projection) applyTerminalNotices(e event.Event) { |
| 15 | var rows []Message |
| 16 | row := Message{Role: "notice", Level: "info"} |
| 17 | switch { |
| 18 | case e.Outcome == event.TurnOutcomeIncompleteRead: |
| 19 | row.Code, row.ReadPause = e.Outcome, e.ReadPause |
| 20 | case e.Outcome == event.TurnOutcomeFinalReadiness: |
| 21 | row.Code, row.Pending, row.Readiness = e.Outcome, true, e.Readiness |
| 22 | row.Content = "Task is not complete; continue the remaining work or checks." |
| 23 | case e.Outcome == event.TurnOutcomeRecoveryPaused: |
| 24 | row.Code, row.Content = e.Outcome, "Automatic recovery paused. You can continue the task." |
| 25 | case e.Outcome == event.TurnOutcomeCompletionUncertain: |
| 26 | row.Code, row.Content = e.Outcome, "The host could not confirm this turn is complete." |
| 27 | case e.Status == event.TurnInterrupted || e.Status == event.TurnRecoveryRequired: |
| 28 | row = interruptedNotice(nil) |
| 29 | case e.Err != nil: |
| 30 | row.Code, row.Level, row.Content, row.Detail = event.NoticeCodeProviderRequestFailed, "warn", e.Err.Error(), e.Detail |
| 31 | row.Diagnostic = e.Diagnostic |
| 32 | default: |
| 33 | row = Message{} |
| 34 | } |
| 35 | if row.Role != "" { |
| 36 | rows = append(rows, row) |
| 37 | } |
| 38 | if e.ReadCompletion != nil { |
| 39 | rows = append(rows, readCompletionMessage(e.ReadCompletion)) |
| 40 | } |
| 41 | if e.ProtocolRecovery != nil && e.Status != event.TurnInterrupted { |
| 42 | rows = append(rows, Message{Role: "notice", Code: "protocol_recovery", Level: "info", Pending: true, |
| 43 | Content: "The interrupted task can continue from valid context.", ProtocolRecovery: e.ProtocolRecovery}) |
| 44 | } |
| 45 | for _, row := range rows { |
| 46 | present := false |
| 47 | for _, existing := range p.buffer.messages { |
| 48 | if existing.message.TurnID == e.TurnID && existing.message.Code == row.Code && row.Code != "" { |
| 49 | present = true |
| 50 | break |
| 51 | } |
| 52 | } |
| 53 | if present { |
| 54 | continue |
| 55 | } |
| 56 | row.RecordID = fmt.Sprintf("terminal:%s:%s", e.TurnID, row.Code) |
| 57 | row.TurnID, row.Source = e.TurnID, e.Source |
| 58 | p.buffer.messages = append(p.buffer.messages, &bufferedMessage{message: row}) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func (p *Projection) retireRecoveryNotices() { |
| 63 | for _, row := range p.buffer.messages { |
| 64 | if row.message.Role == "notice" && (row.message.ProtocolRecovery != nil || row.message.Readiness != nil) { |
| 65 | row.message.Pending = false |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func readCompletionMessage(receipt *provider.ReadCompletion) Message { |
| 71 | parts := make([]string, 0, len(receipt.Reads)) |
| 72 | for _, read := range receipt.Reads { |
| 73 | parts = append(parts, fmt.Sprintf("%s · %s · covered=%v", read.Path, read.Verdict, read.Covered)) |
| 74 | } |
| 75 | return Message{Role: "notice", Code: "read_completion", Level: "info", Content: "Partial read coverage was accepted for this turn.", |
| 76 | Detail: strings.Join(parts, "\n"), ReadCompletion: receipt} |
| 77 | } |
| 78 |