| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "sort" |
| 9 | |
| 10 | "reasonix/internal/secrets" |
| 11 | "reasonix/internal/session" |
| 12 | ) |
| 13 | |
| 14 | // WriteColdSessionDiagnostics serializes the durable evidence of a fixed |
| 15 | // snapshot without starting or taking ownership of a session runtime. |
| 16 | func WriteColdSessionDiagnostics(ctx context.Context, dst io.Writer, query *session.Query, snapshot session.ExportSnapshot, metadata GoalDiagnosticMetadata, extra map[string]any) error { |
| 17 | if query == nil || snapshot.Ref.SessionID == "" { |
| 18 | return errors.New("cold session diagnostics require a canonical snapshot") |
| 19 | } |
| 20 | if err := query.ValidateExportSourceForRef(snapshot.Ref, snapshot); err != nil { |
| 21 | return err |
| 22 | } |
| 23 | if metadata.Capabilities == nil { |
| 24 | metadata.Capabilities = []string{} |
| 25 | } |
| 26 | fillGoalDiagnosticBuildMetadata(&metadata) |
| 27 | unavailable := []string{"runtime, submissionDiagnostics, shellDiagnostics and process-local lifecycle history are unavailable for a cold session"} |
| 28 | if snapshot.ReadIncomplete { |
| 29 | unavailable = append(unavailable, "snapshot capture encountered unreadable durable events; only the readable prefix is available") |
| 30 | } |
| 31 | if _, err := io.WriteString(dst, "{\n"); err != nil { |
| 32 | return err |
| 33 | } |
| 34 | fields := []struct { |
| 35 | name string |
| 36 | value any |
| 37 | }{ |
| 38 | {"schemaVersion", 1}, |
| 39 | {"exportedAt", snapshot.CapturedAt}, |
| 40 | {"metadata", metadata}, |
| 41 | {"runtime", nil}, |
| 42 | {"observation", nil}, |
| 43 | {"submissionDiagnostics", nil}, |
| 44 | {"shellDiagnostics", nil}, |
| 45 | {"lifecycleDiagnostics", nil}, |
| 46 | } |
| 47 | for _, field := range fields { |
| 48 | if err := writeGoalDiagnosticField(dst, field.name, field.value, true); err != nil { |
| 49 | return err |
| 50 | } |
| 51 | } |
| 52 | names := make([]string, 0, len(extra)) |
| 53 | for name := range extra { |
| 54 | names = append(names, name) |
| 55 | } |
| 56 | sort.Strings(names) |
| 57 | for _, name := range names { |
| 58 | encoded, err := json.Marshal(extra[name]) |
| 59 | if err != nil { |
| 60 | return err |
| 61 | } |
| 62 | redacted := json.RawMessage(secrets.Redact(string(encoded))) |
| 63 | if !json.Valid(redacted) { |
| 64 | return errors.New("invalid redacted diagnostic field") |
| 65 | } |
| 66 | if err = writeGoalDiagnosticField(dst, name, redacted, true); err != nil { |
| 67 | return err |
| 68 | } |
| 69 | } |
| 70 | through, traversalErr, err := writeColdDiagnosticCommits(ctx, dst, query, snapshot) |
| 71 | if err != nil { |
| 72 | return err |
| 73 | } |
| 74 | if traversalErr != nil { |
| 75 | unavailable = append(unavailable, "durable event traversal failed: "+secrets.RedactError(traversalErr)) |
| 76 | } |
| 77 | if err = writeGoalDiagnosticField(dst, "activationChanges", []goalDiagnosticTransition{}, true); err != nil { |
| 78 | return err |
| 79 | } |
| 80 | if err = writeGoalDiagnosticField(dst, "acceptedThrough", through, true); err != nil { |
| 81 | return err |
| 82 | } |
| 83 | if err = writeGoalDiagnosticField(dst, "durableThrough", through, true); err != nil { |
| 84 | return err |
| 85 | } |
| 86 | if err = writeGoalDiagnosticField(dst, "unavailable", unavailable, false); err != nil { |
| 87 | return err |
| 88 | } |
| 89 | _, err = io.WriteString(dst, "}\n") |
| 90 | return err |
| 91 | } |
| 92 | |
| 93 | func writeColdDiagnosticCommits(ctx context.Context, dst io.Writer, query *session.Query, snapshot session.ExportSnapshot) (through uint64, traversalErr, err error) { |
| 94 | if _, err = io.WriteString(dst, " \"commits\": ["); err != nil { |
| 95 | return 0, nil, err |
| 96 | } |
| 97 | first := true |
| 98 | var destinationError error |
| 99 | traversalErr = query.StreamExportCommits(ctx, snapshot, func(commit session.Commit) error { |
| 100 | if commit.LastSequence() > snapshot.SnapshotSequence { |
| 101 | return nil |
| 102 | } |
| 103 | encoded, marshalErr := json.MarshalIndent(commit, " ", " ") |
| 104 | if marshalErr != nil { |
| 105 | return marshalErr |
| 106 | } |
| 107 | encoded = []byte(secrets.Redact(string(encoded))) |
| 108 | if !json.Valid(encoded) { |
| 109 | return errors.New("redacted cold diagnostic commit is not valid JSON") |
| 110 | } |
| 111 | separator := "\n " |
| 112 | if !first { |
| 113 | separator = ",\n " |
| 114 | } |
| 115 | if _, writeErr := io.WriteString(dst, separator); writeErr != nil { |
| 116 | destinationError = writeErr |
| 117 | return writeErr |
| 118 | } |
| 119 | if _, writeErr := dst.Write(encoded); writeErr != nil { |
| 120 | destinationError = writeErr |
| 121 | return writeErr |
| 122 | } |
| 123 | first = false |
| 124 | through = commit.LastSequence() |
| 125 | return nil |
| 126 | }) |
| 127 | if destinationError != nil { |
| 128 | return 0, nil, destinationError |
| 129 | } |
| 130 | if err = ctx.Err(); err != nil { |
| 131 | return 0, nil, err |
| 132 | } |
| 133 | if !first { |
| 134 | if _, err = io.WriteString(dst, "\n "); err != nil { |
| 135 | return 0, nil, err |
| 136 | } |
| 137 | } |
| 138 | if _, err = io.WriteString(dst, "],\n"); err != nil { |
| 139 | return 0, nil, err |
| 140 | } |
| 141 | return through, traversalErr, nil |
| 142 | } |
| 143 |