| 1 | package browserops |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "io" |
| 7 | "os" |
| 8 | "sort" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | // DiagnosticOperation deliberately omits grants, document tokens, argument |
| 13 | // digests and free-form errors. Tool errors remain in the redacted transcript. |
| 14 | type DiagnosticOperation struct { |
| 15 | OperationID string `json:"operationId"` |
| 16 | TabID string `json:"tabId"` |
| 17 | Action string `json:"action"` |
| 18 | State State `json:"state"` |
| 19 | ReservedAt time.Time `json:"reservedAt"` |
| 20 | SettledAt time.Time `json:"settledAt"` |
| 21 | } |
| 22 | type DiagnosticSnapshot struct { |
| 23 | Available bool `json:"available"` |
| 24 | Truncated bool `json:"truncated"` |
| 25 | FieldsTruncated bool `json:"fieldsTruncated"` |
| 26 | Matched int `json:"matched"` |
| 27 | Limit int `json:"limit"` |
| 28 | Operations []DiagnosticOperation `json:"operations"` |
| 29 | Coverage string `json:"coverage"` |
| 30 | } |
| 31 | |
| 32 | func diagnosticSnapshot(file ledgerFile, scope string) DiagnosticSnapshot { |
| 33 | const limit = 200 |
| 34 | out := DiagnosticSnapshot{Available: true, Limit: limit, Operations: []DiagnosticOperation{}, Coverage: "Only records with explicit diagnostic attribution; pre-upgrade or downgraded records cannot be attributed. Reserved states are observed without recovery or replay."} |
| 35 | if scope == "" { |
| 36 | out.Available = false |
| 37 | return out |
| 38 | } |
| 39 | for _, op := range file.Operations { |
| 40 | if op == nil || op.DiagnosticScope != scope { |
| 41 | continue |
| 42 | } |
| 43 | out.Matched++ |
| 44 | bounded := func(value string, limit int) string { |
| 45 | if len(value) > limit { |
| 46 | out.FieldsTruncated = true |
| 47 | return value[:limit] |
| 48 | } |
| 49 | return value |
| 50 | } |
| 51 | out.Operations = append(out.Operations, DiagnosticOperation{bounded(op.ID, 100), bounded(op.TabID, 160), bounded(op.Action, 64), State(bounded(string(op.State), 32)), op.ReservedAt, op.SettledAt}) |
| 52 | // Keep memory bounded even when the on-disk ledger is large. |
| 53 | sort.Slice(out.Operations, func(i, j int) bool { |
| 54 | if out.Operations[i].ReservedAt.Equal(out.Operations[j].ReservedAt) { |
| 55 | return out.Operations[i].OperationID < out.Operations[j].OperationID |
| 56 | } |
| 57 | return out.Operations[i].ReservedAt.After(out.Operations[j].ReservedAt) |
| 58 | }) |
| 59 | if len(out.Operations) > limit { |
| 60 | out.Operations = out.Operations[:limit] |
| 61 | } |
| 62 | } |
| 63 | out.Truncated = out.Matched > limit |
| 64 | return out |
| 65 | } |
| 66 | |
| 67 | func (l *Ledger) Diagnostics(scope string) DiagnosticSnapshot { |
| 68 | l.mu.Lock() |
| 69 | defer l.mu.Unlock() |
| 70 | return diagnosticSnapshot(l.file, scope) |
| 71 | } |
| 72 | |
| 73 | // ReadDiagnostics never calls Open: an export must not settle reserved writes. |
| 74 | func ReadDiagnostics(path, scope string) (DiagnosticSnapshot, error) { |
| 75 | f, err := os.Open(path) |
| 76 | if err != nil { |
| 77 | return DiagnosticSnapshot{}, err |
| 78 | } |
| 79 | defer f.Close() |
| 80 | const budget = 64 << 20 |
| 81 | data, err := io.ReadAll(io.LimitReader(f, budget+1)) |
| 82 | if err != nil { |
| 83 | return DiagnosticSnapshot{}, err |
| 84 | } |
| 85 | if len(data) > budget { |
| 86 | return DiagnosticSnapshot{}, errors.New("browser ledger exceeds diagnostic read budget") |
| 87 | } |
| 88 | var file ledgerFile |
| 89 | if err = json.Unmarshal(data, &file); err != nil { |
| 90 | return DiagnosticSnapshot{}, err |
| 91 | } |
| 92 | if file.Version != ledgerVersion { |
| 93 | return DiagnosticSnapshot{}, errors.New("unsupported browser ledger version") |
| 94 | } |
| 95 | return diagnosticSnapshot(file, scope), nil |
| 96 | } |
| 97 |