total-recall-brain 3.18.2 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +41 -5
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/HealthPage.tsx +12 -16
  12. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  13. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  14. package/frontend/src/pages/RulesPage.tsx +41 -9
  15. package/frontend/src/pages/SecretsPage.tsx +316 -3
  16. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  17. package/frontend/src/pages/SettingsPage.tsx +228 -86
  18. package/package.json +1 -1
  19. package/src/cli/agent-dir.mjs +17 -2
  20. package/src/cli/chat.mjs +1 -1
  21. package/src/cli/daemon.mjs +2 -1
  22. package/src/cli/dream.mjs +14 -10
  23. package/src/cli/forget.mjs +14 -2
  24. package/src/cli/friction.mjs +1 -1
  25. package/src/cli/import-rules.mjs +8 -3
  26. package/src/cli/ingest.mjs +3 -2
  27. package/src/cli/lint.mjs +9 -6
  28. package/src/cli/rebuild.mjs +9 -4
  29. package/src/cli/remember.mjs +21 -15
  30. package/src/cli/secret.mjs +184 -4
  31. package/src/cli/share.mjs +5 -3
  32. package/src/cli/skill-projection.mjs +21 -2
  33. package/src/cli/snapshot.mjs +10 -8
  34. package/src/cli/update.mjs +2 -1
  35. package/src/core/clarity-rewriter.mjs +31 -12
  36. package/src/core/config.mjs +10 -4
  37. package/src/core/daemon-loop.mjs +33 -6
  38. package/src/core/embeddings.mjs +51 -3
  39. package/src/core/memory-title.mjs +70 -0
  40. package/src/core/notifications.mjs +204 -34
  41. package/src/core/package-auto-update.mjs +99 -3
  42. package/src/core/pairing.mjs +168 -0
  43. package/src/core/provider-account-sync.mjs +1145 -0
  44. package/src/core/provider-catalog.mjs +11 -4
  45. package/src/core/research.mjs +98 -24
  46. package/src/core/runtime.mjs +11 -1
  47. package/src/core/scheduler.mjs +31 -9
  48. package/src/core/schema.mjs +23 -0
  49. package/src/core/secret-integration-research.mjs +325 -0
  50. package/src/core/secrets-store.mjs +284 -14
  51. package/src/core/secrets-sync.mjs +1 -1
  52. package/src/core/skills-registry.mjs +12 -0
  53. package/src/core/source-adapters.mjs +247 -22
  54. package/src/core/surface.mjs +4 -4
  55. package/src/core/task-executors.mjs +161 -14
  56. package/src/core/throttled-fetch.mjs +5 -0
  57. package/src/core/vault.mjs +7 -1
  58. package/src/core/webhook-handlers.mjs +123 -14
  59. package/src/server/auth.mjs +6 -0
  60. package/src/server/index.mjs +56 -0
  61. package/src/server/rest.mjs +2 -0
  62. package/src/server/route-manifest.json +40 -0
  63. package/src/server/routes/_shared.mjs +33 -1
  64. package/src/server/routes/config.mjs +64 -23
  65. package/src/server/routes/context.mjs +14 -9
  66. package/src/server/routes/dashboard.mjs +39 -18
  67. package/src/server/routes/docs.mjs +27 -13
  68. package/src/server/routes/dream.mjs +10 -11
  69. package/src/server/routes/export.mjs +43 -12
  70. package/src/server/routes/field.mjs +11 -9
  71. package/src/server/routes/graph.mjs +31 -13
  72. package/src/server/routes/import.mjs +35 -16
  73. package/src/server/routes/instructions.mjs +41 -5
  74. package/src/server/routes/memory.mjs +61 -21
  75. package/src/server/routes/mesh.mjs +2 -1
  76. package/src/server/routes/notifications.mjs +103 -0
  77. package/src/server/routes/rules.mjs +75 -20
  78. package/src/server/routes/secrets.mjs +110 -4
  79. package/src/server/routes/share.mjs +2 -1
  80. package/src/server/routes/ssss.mjs +11 -3
  81. package/src/server/routes/system.mjs +20 -1
  82. package/src/server/routes/update.mjs +33 -7
  83. package/src/server/routes/vault.mjs +53 -28
@@ -1,23 +1,54 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
3
  import RulesPage from './RulesPage';
4
4
 
5
- // Mock simple API and context if needed
5
+ const apiFetch = vi.fn();
6
+ const getApiBase = vi.fn().mockReturnValue('');
7
+
6
8
  vi.mock('../api', () => ({
7
- apiFetch: vi.fn().mockResolvedValue({ json: () => Promise.resolve({ rules: [] }) }),
8
- getApiBase: vi.fn().mockReturnValue(''),
9
+ apiFetch: (...args: unknown[]) => apiFetch(...args),
10
+ getApiBase: () => getApiBase(),
9
11
  }));
10
12
 
11
13
  describe('RulesPage Component', () => {
12
- it('renders the RulesPage title', () => {
13
- // In a real test, we would wrap with Router/Context providers if needed
14
- // Assuming RulesPage is isolated enough or we mock context
15
- try {
16
- render(<RulesPage activeBrainId="test-brain" />);
17
- // We expect the page to render without crashing
18
- expect(true).toBe(true);
19
- } catch (e) {
20
- // Ignored for basic mount check
21
- }
14
+ beforeEach(() => {
15
+ vi.clearAllMocks();
16
+ apiFetch.mockResolvedValue({
17
+ ok: true,
18
+ json: async () => ({
19
+ rules: [
20
+ {
21
+ slug: 'test-inv',
22
+ category: 'invariants',
23
+ title: 'Always test',
24
+ status: 'active',
25
+ importance: 4,
26
+ body: 'Run tests.',
27
+ scope: 'global',
28
+ },
29
+ ],
30
+ count: 1,
31
+ }),
32
+ });
33
+ });
34
+
35
+ it('renders the Agent Rules title and rule cards', async () => {
36
+ render(<RulesPage activeBrainId="test-brain" />);
37
+ expect(await screen.findByText('Agent Rules')).toBeTruthy();
38
+ await waitFor(() => {
39
+ expect(screen.getByText('Always test')).toBeTruthy();
40
+ });
41
+ expect(screen.getByTestId('rule-card')).toBeTruthy();
42
+ });
43
+
44
+ it('shows error state when API fails', async () => {
45
+ apiFetch.mockResolvedValueOnce({
46
+ ok: false,
47
+ status: 500,
48
+ json: async () => ({ error: 'Failed to load agent rules' }),
49
+ });
50
+ render(<RulesPage activeBrainId="test-brain" />);
51
+ expect(await screen.findByTestId('rules-error')).toBeTruthy();
52
+ expect(screen.getByText(/Failed to load agent rules/i)).toBeTruthy();
22
53
  });
23
54
  });
@@ -14,6 +14,7 @@ interface RuleNode {
14
14
  export default function RulesPage({ activeBrainId }: { activeBrainId: string }) {
15
15
  const [rules, setRules] = useState<RuleNode[]>([]);
16
16
  const [loading, setLoading] = useState(true);
17
+ const [error, setError] = useState<string | null>(null);
17
18
 
18
19
  useEffect(() => {
19
20
  fetchRules();
@@ -21,14 +22,19 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
21
22
 
22
23
  const fetchRules = async () => {
23
24
  setLoading(true);
25
+ setError(null);
24
26
  try {
25
27
  const res = await apiFetch(getApiBase() + '/api/rules');
26
- const data = await res.json();
27
- if (data.rules) {
28
- setRules(data.rules);
28
+ if (!res.ok) {
29
+ const errBody = await res.json().catch(() => ({}));
30
+ throw new Error(errBody.error || errBody.message || `HTTP ${res.status}`);
29
31
  }
32
+ const data = await res.json();
33
+ setRules(Array.isArray(data.rules) ? data.rules : []);
30
34
  } catch (err) {
31
35
  console.error('Failed to fetch rules:', err);
36
+ setError(err instanceof Error ? err.message : 'Failed to fetch rules');
37
+ setRules([]);
32
38
  } finally {
33
39
  setLoading(false);
34
40
  }
@@ -69,9 +75,11 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
69
75
 
70
76
  const renderRuleCard = (rule: RuleNode, colorCode: string) => {
71
77
  const isArchived = rule.status === 'archived';
78
+ const stars = Math.min(5, Math.max(0, Number(rule.importance) || 0));
72
79
  return (
73
80
  <div
74
- key={rule.slug}
81
+ key={`${rule.scope}:${rule.slug}`}
82
+ data-testid="rule-card"
75
83
  style={{
76
84
  border: `1px solid ${isArchived ? 'var(--border)' : colorCode}`,
77
85
  opacity: isArchived ? 0.6 : 1,
@@ -90,13 +98,13 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
90
98
  <span style={{ fontSize: 12, color: 'var(--text-tertiary)', background: 'var(--bg-primary)', padding: '2px 6px', borderRadius: 4 }}>
91
99
  {rule.scope} brain
92
100
  </span>
93
- <span style={{ fontSize: 12, color: 'gold' }}>
94
- {'★'.repeat(rule.importance) + '☆'.repeat(5 - rule.importance)}
101
+ <span style={{ fontSize: 12, color: 'gold' }} aria-label={`Importance ${stars} of 5`}>
102
+ {'★'.repeat(stars) + '☆'.repeat(5 - stars)}
95
103
  </span>
96
104
  </div>
97
105
  </div>
98
106
  <div style={{ fontSize: 14, color: 'var(--text-secondary)', whiteSpace: 'pre-wrap' }}>
99
- {rule.body}
107
+ {rule.body || '(no body)'}
100
108
  </div>
101
109
  <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
102
110
  {isArchived ? (
@@ -120,11 +128,11 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
120
128
  };
121
129
 
122
130
  if (loading) {
123
- return <div style={{ padding: 20 }}>Loading rules...</div>;
131
+ return <div style={{ padding: 20 }} data-testid="rules-loading">Loading rules...</div>;
124
132
  }
125
133
 
126
134
  return (
127
- <div style={{ padding: 40, maxWidth: 1000, margin: '0 auto', height: '100%', overflowY: 'auto' }}>
135
+ <div style={{ padding: 40, maxWidth: 1000, margin: '0 auto', height: '100%', overflowY: 'auto' }} data-testid="rules-page">
128
136
  <header style={{ marginBottom: 40 }}>
129
137
  <h1 style={{ margin: '0 0 8px 0', fontSize: 28, color: 'var(--text-primary)' }}>Agent Rules</h1>
130
138
  <p style={{ margin: 0, color: 'var(--text-secondary)', fontSize: 16 }}>
@@ -132,6 +140,30 @@ export default function RulesPage({ activeBrainId }: { activeBrainId: string })
132
140
  </p>
133
141
  </header>
134
142
 
143
+ {error && (
144
+ <div
145
+ data-testid="rules-error"
146
+ role="alert"
147
+ style={{
148
+ marginBottom: 24,
149
+ padding: 12,
150
+ borderRadius: 8,
151
+ border: '1px solid #ef4444',
152
+ background: 'rgba(239, 68, 68, 0.08)',
153
+ color: '#ef4444',
154
+ }}
155
+ >
156
+ Failed to load rules: {error}
157
+ <button
158
+ type="button"
159
+ onClick={() => fetchRules()}
160
+ style={{ marginLeft: 12, padding: '2px 8px', cursor: 'pointer' }}
161
+ >
162
+ Retry
163
+ </button>
164
+ </div>
165
+ )}
166
+
135
167
  <section style={{ marginBottom: 40 }}>
136
168
  <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
137
169
  <h2 style={{ margin: 0, color: '#ef4444' }}>Invariants</h2>
@@ -32,6 +32,7 @@ import {
32
32
  addSecret,
33
33
  triggerSync,
34
34
  getSyncStatus,
35
+ runAccountSync,
35
36
  type SyncNodeStatus
36
37
  } from '../api'
37
38
  import type { ConfigJson, GeminiModelInfo } from '../types'
@@ -55,6 +56,8 @@ type MetaEdit = {
55
56
  auto_rotate: boolean
56
57
  notes: string
57
58
  project_path: string
59
+ tracking_exempt: boolean
60
+ shared_value_ok: boolean
58
61
  }
59
62
 
60
63
  function metaForm(k: SecretCatalogKey): MetaEdit {
@@ -71,6 +74,8 @@ function metaForm(k: SecretCatalogKey): MetaEdit {
71
74
  auto_rotate: !!k.auto_rotate,
72
75
  notes: k.notes || '',
73
76
  project_path: k.project_path || '',
77
+ tracking_exempt: !!k.tracking_exempt,
78
+ shared_value_ok: !!k.shared_value_ok,
74
79
  }
75
80
  }
76
81
 
@@ -179,7 +184,10 @@ export default function SecretsPage() {
179
184
  auto_rotate: false,
180
185
  notes: '',
181
186
  project_path: '',
187
+ tracking_exempt: false,
188
+ shared_value_ok: false,
182
189
  })
190
+ const [accountSyncBusy, setAccountSyncBusy] = useState(false)
183
191
  const [rotateVal, setRotateVal] = useState('')
184
192
  const [saving, setSaving] = useState(false)
185
193
  const [status, setStatus] = useState('')
@@ -513,7 +521,7 @@ export default function SecretsPage() {
513
521
  setSaving(false)
514
522
  return
515
523
  }
516
- const patch = {
524
+ const patch: Record<string, unknown> = {
517
525
  label: edit.label.trim() || null,
518
526
  provider: edit.provider.trim() || null,
519
527
  repos: reposList,
@@ -527,7 +535,10 @@ export default function SecretsPage() {
527
535
  auto_rotate: !!edit.auto_rotate,
528
536
  notes: edit.notes.trim() || null,
529
537
  project_path: edit.project_path.trim() || null,
538
+ tracking_exempt: !!edit.tracking_exempt,
539
+ shared_value_ok: !!edit.shared_value_ok,
530
540
  }
541
+ if (edit.tracking_exempt) patch.tracking_status = 'exempt'
531
542
  await updateSecretMeta(selected.key, patch)
532
543
  setStatus('Metadata saved')
533
544
  await loadCatalog()
@@ -705,6 +716,37 @@ export default function SecretsPage() {
705
716
  <button type="button" className="btn btn-ghost btn-sm" onClick={() => void runScan()}>
706
717
  Migrate import
707
718
  </button>
719
+ <button
720
+ type="button"
721
+ className="btn btn-ghost btn-sm"
722
+ disabled={accountSyncBusy}
723
+ onClick={async () => {
724
+ setAccountSyncBusy(true)
725
+ setError(null)
726
+ try {
727
+ const r = (await runAccountSync({ strict: true })) as {
728
+ healthy?: boolean
729
+ ok?: number
730
+ errors?: number
731
+ message?: string
732
+ total?: number
733
+ }
734
+ setStatus(
735
+ r.healthy
736
+ ? `Account sync OK — ${r.ok ?? 0}/${r.total ?? 0} tracked`
737
+ : `Account sync: ${r.errors ?? '?'} tracking error(s) — ${r.message || 'see catalog'}`,
738
+ )
739
+ if (!r.healthy) setError(r.message || 'Tracking errors remain after account-sync')
740
+ await loadCatalog()
741
+ } catch (e: unknown) {
742
+ setError((e as Error).message)
743
+ } finally {
744
+ setAccountSyncBusy(false)
745
+ }
746
+ }}
747
+ >
748
+ {accountSyncBusy ? 'Syncing accounts…' : 'Sync provider accounts'}
749
+ </button>
708
750
  <button type="button" className="btn btn-ghost btn-sm" onClick={() => void loadCatalog()}>
709
751
  Refresh
710
752
  </button>
@@ -741,6 +783,14 @@ export default function SecretsPage() {
741
783
  <li>
742
784
  <strong>import-env</strong> = one-time migrate. <strong>PATs</strong> = brain API tokens only.
743
785
  </li>
786
+ <li>
787
+ <strong>Account tracking</strong> — every billable key must have live account/usage/subscription data
788
+ or an explicit tracking exempt + monthly cost.
789
+ </li>
790
+ <li>
791
+ <strong>Shared values</strong> — same credential material under multiple key names/apps is an error;
792
+ issue a unique API key per app, then rotate each secret name.
793
+ </li>
744
794
  </ul>
745
795
  </div>
746
796
  )}
@@ -809,6 +859,16 @@ export default function SecretsPage() {
809
859
  value={String(summary.multi_repo_violations || 0)}
810
860
  accent={summary.multi_repo_violations ? '#f87171' : '#64748b'}
811
861
  />
862
+ <SummaryCard
863
+ label="Shared keys"
864
+ value={String(summary.shared_value_groups || 0)}
865
+ accent={summary.shared_value_healthy === false ? '#f87171' : '#64748b'}
866
+ />
867
+ <SummaryCard
868
+ label="Tracking errors"
869
+ value={String(summary.tracking_errors || 0)}
870
+ accent={summary.tracking_healthy === false ? '#f87171' : '#34d399'}
871
+ />
812
872
  <SummaryCard
813
873
  label="Developer"
814
874
  value={String(summary.developer_keys || 0)}
@@ -839,6 +899,84 @@ export default function SecretsPage() {
839
899
  </div>
840
900
  )}
841
901
 
902
+ {summary?.shared_value_healthy === false && (
903
+ <div
904
+ className="card"
905
+ style={{
906
+ marginBottom: 16,
907
+ padding: 14,
908
+ borderColor: 'rgba(251,146,60,0.55)',
909
+ background: 'rgba(251,146,60,0.1)',
910
+ fontSize: 13,
911
+ lineHeight: 1.5,
912
+ }}
913
+ >
914
+ <strong style={{ color: '#fb923c' }}>
915
+ Shared credential values — {summary.shared_value_groups || 0} group(s) ·{' '}
916
+ {summary.shared_value_keys || 0} key(s)
917
+ </strong>
918
+ <p style={{ margin: '6px 0 8px', color: 'var(--text-secondary)' }}>
919
+ The same API key material is stored under multiple secret names and/or apps. Issue a{' '}
920
+ <strong>unique key per app</strong> in the vendor console, then rotate each secret name.
921
+ CLI: <code>npx total-recall secret shared</code>
922
+ </p>
923
+ {(summary.shared_value_errors || []).slice(0, 8).map((g) => (
924
+ <div
925
+ key={g.fingerprint || (g.keys || []).join(',')}
926
+ style={{
927
+ fontSize: 11,
928
+ fontFamily: 'ui-monospace, monospace',
929
+ marginBottom: 6,
930
+ color: 'var(--text-secondary)',
931
+ }}
932
+ >
933
+ <span style={{ color: '#fb923c' }}>fp {g.fingerprint || '—'}</span>
934
+ {' · apps ['}
935
+ {(g.apps || []).join(', ')}
936
+ {'] · '}
937
+ {(g.keys || []).join(', ')}
938
+ </div>
939
+ ))}
940
+ </div>
941
+ )}
942
+
943
+ {summary?.tracking_healthy === false && (
944
+ <div
945
+ className="card"
946
+ style={{
947
+ marginBottom: 16,
948
+ padding: 14,
949
+ borderColor: 'rgba(248,113,113,0.5)',
950
+ background: 'rgba(248,113,113,0.08)',
951
+ fontSize: 13,
952
+ lineHeight: 1.5,
953
+ }}
954
+ >
955
+ <strong style={{ color: '#f87171' }}>
956
+ Account / usage tracking errors — {summary.tracking_errors || 0}
957
+ </strong>
958
+ <p style={{ margin: '6px 0 8px', color: 'var(--text-secondary)' }}>
959
+ Every set credential must be tracked (live provider account/usage API) or explicitly exempt
960
+ with a monthly cost. Run <strong>Sync provider accounts</strong> or CLI{' '}
961
+ <code>npx total-recall secret account-sync</code>.
962
+ </p>
963
+ {(summary.tracking_error_keys || []).slice(0, 10).map((e) => (
964
+ <div
965
+ key={e.key}
966
+ style={{
967
+ fontSize: 11,
968
+ fontFamily: 'ui-monospace, monospace',
969
+ marginBottom: 4,
970
+ color: 'var(--text-secondary)',
971
+ }}
972
+ >
973
+ <span style={{ color: '#f87171' }}>{e.key}</span>
974
+ {e.provider ? ` (${e.provider})` : ''}: {(e.error || '').slice(0, 120)}
975
+ </div>
976
+ ))}
977
+ </div>
978
+ )}
979
+
842
980
  {catalog?.by_provider && catalog.by_provider.length > 0 && (
843
981
  <>
844
982
  <button
@@ -992,7 +1130,9 @@ export default function SecretsPage() {
992
1130
  rotate_every_days: '',
993
1131
  auto_rotate: false,
994
1132
  notes: '',
995
- project_path: ''
1133
+ project_path: '',
1134
+ tracking_exempt: false,
1135
+ shared_value_ok: false,
996
1136
  })
997
1137
  setNewSecretKeyName('')
998
1138
  setNewSecretVal('')
@@ -1594,6 +1734,130 @@ export default function SecretsPage() {
1594
1734
  )}
1595
1735
  </PanelSection>
1596
1736
 
1737
+ <PanelSection
1738
+ title="Tracking & shared-key health"
1739
+ hint="Billable keys need live account/usage APIs; shared values across apps must be unique per app"
1740
+ >
1741
+ <div style={{ fontSize: 12, marginBottom: 10, lineHeight: 1.45 }}>
1742
+ <div>
1743
+ Tracking:{' '}
1744
+ <strong
1745
+ style={{
1746
+ color:
1747
+ selected.tracking_status === 'ok' || selected.tracking_exempt
1748
+ ? '#34d399'
1749
+ : selected.tracking_status === 'exempt'
1750
+ ? '#fbbf24'
1751
+ : '#f87171',
1752
+ }}
1753
+ >
1754
+ {selected.tracking_exempt
1755
+ ? 'exempt'
1756
+ : selected.tracking_status || 'never synced'}
1757
+ </strong>
1758
+ {selected.key_valid != null && (
1759
+ <span style={{ color: 'var(--text-tertiary)' }}>
1760
+ {' '}
1761
+ · key {selected.key_valid ? 'valid' : 'invalid'}
1762
+ </span>
1763
+ )}
1764
+ </div>
1765
+ {selected.tracking_error && (
1766
+ <div style={{ color: '#f87171', fontSize: 11, marginTop: 4 }}>
1767
+ {selected.tracking_error}
1768
+ </div>
1769
+ )}
1770
+ {selected.tracking_usage != null && (
1771
+ <pre
1772
+ style={{
1773
+ fontSize: 10,
1774
+ marginTop: 6,
1775
+ padding: 8,
1776
+ borderRadius: 6,
1777
+ background: 'rgba(0,0,0,0.25)',
1778
+ overflow: 'auto',
1779
+ maxHeight: 100,
1780
+ }}
1781
+ >
1782
+ {JSON.stringify(selected.tracking_usage, null, 2)}
1783
+ </pre>
1784
+ )}
1785
+ <div style={{ marginTop: 8 }}>
1786
+ Shared value:{' '}
1787
+ <strong
1788
+ style={{
1789
+ color:
1790
+ selected.shared_value && selected.shared_value_severity === 'error'
1791
+ ? '#fb923c'
1792
+ : selected.shared_value
1793
+ ? '#fbbf24'
1794
+ : '#34d399',
1795
+ }}
1796
+ >
1797
+ {selected.shared_value
1798
+ ? selected.shared_value_severity === 'error'
1799
+ ? 'ERROR — same material in multiple keys/apps'
1800
+ : 'shared (waived)'
1801
+ : 'unique'}
1802
+ </strong>
1803
+ </div>
1804
+ {selected.shared_with && selected.shared_with.length > 0 && (
1805
+ <div style={{ fontSize: 11, marginTop: 4, color: 'var(--text-secondary)' }}>
1806
+ Same value as:{' '}
1807
+ {selected.shared_with
1808
+ .map((s) => `${s.key} (${s.app})`)
1809
+ .join(', ')}
1810
+ </div>
1811
+ )}
1812
+ {selected.shared_value_error && (
1813
+ <div style={{ color: '#fb923c', fontSize: 11, marginTop: 4 }}>
1814
+ {selected.shared_value_error}
1815
+ </div>
1816
+ )}
1817
+ </div>
1818
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 8 }}>
1819
+ <input
1820
+ type="checkbox"
1821
+ checked={!!edit.tracking_exempt}
1822
+ onChange={(e) => setEdit({ ...edit, tracking_exempt: e.target.checked })}
1823
+ />
1824
+ Tracking exempt (no vendor account API — set monthly cost)
1825
+ </label>
1826
+ <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, marginBottom: 8 }}>
1827
+ <input
1828
+ type="checkbox"
1829
+ checked={!!edit.shared_value_ok}
1830
+ onChange={(e) => setEdit({ ...edit, shared_value_ok: e.target.checked })}
1831
+ />
1832
+ Shared value OK (intentional mirror — prefer unique keys per app)
1833
+ </label>
1834
+ <button
1835
+ type="button"
1836
+ className="btn btn-ghost btn-sm"
1837
+ disabled={accountSyncBusy}
1838
+ style={{ marginTop: 4 }}
1839
+ onClick={async () => {
1840
+ setAccountSyncBusy(true)
1841
+ try {
1842
+ const r = (await runAccountSync({ key: selected.key, strict: true })) as {
1843
+ tracking_status?: string
1844
+ error?: string
1845
+ }
1846
+ setStatus(
1847
+ `Synced ${selected.key}: ${r.tracking_status || '?'}${r.error ? ` — ${r.error.slice(0, 80)}` : ''}`,
1848
+ )
1849
+ await loadCatalog()
1850
+ } catch (e: unknown) {
1851
+ setError((e as Error).message)
1852
+ } finally {
1853
+ setAccountSyncBusy(false)
1854
+ }
1855
+ }}
1856
+ >
1857
+ Re-probe this key
1858
+ </button>
1859
+ </PanelSection>
1860
+
1597
1861
  <PanelSection title="Docs & rotation">
1598
1862
  {edit.provider.trim().toLowerCase() === 'headscale' && (
1599
1863
  <Field
@@ -2203,6 +2467,8 @@ export default function SecretsPage() {
2203
2467
 
2204
2468
  const DEVELOPER_ID = '__developer__'
2205
2469
  const MULTI_ERROR_ID = '__multi_repo_error__'
2470
+ const SHARED_ERROR_ID = '__shared_value_error__'
2471
+ const TRACKING_ERROR_ID = '__tracking_error__'
2206
2472
 
2207
2473
  function groupKeysByRepo(keys: SecretCatalogKey[]): {
2208
2474
  id: string
@@ -2213,8 +2479,21 @@ function groupKeysByRepo(keys: SecretCatalogKey[]): {
2213
2479
  const map = new Map<string, SecretCatalogKey[]>()
2214
2480
  const developer: SecretCatalogKey[] = []
2215
2481
  const multiError: SecretCatalogKey[] = []
2482
+ const sharedError: SecretCatalogKey[] = []
2483
+ const trackingError: SecretCatalogKey[] = []
2216
2484
 
2217
2485
  for (const k of keys) {
2486
+ if (k.shared_value && k.shared_value_severity === 'error') {
2487
+ sharedError.push(k)
2488
+ }
2489
+ if (
2490
+ k.set &&
2491
+ k.tracking_status !== 'ok' &&
2492
+ k.tracking_status !== 'exempt' &&
2493
+ !k.tracking_exempt
2494
+ ) {
2495
+ trackingError.push(k)
2496
+ }
2218
2497
  const repos = (k.repos || []).map((r) => r.trim()).filter(Boolean)
2219
2498
  if (repos.length > 1 || k.multi_repo_error) {
2220
2499
  multiError.push(k)
@@ -2238,6 +2517,22 @@ function groupKeysByRepo(keys: SecretCatalogKey[]): {
2238
2517
  }[] = []
2239
2518
 
2240
2519
  // Errors first so they are impossible to miss
2520
+ if (sharedError.length) {
2521
+ sections.push({
2522
+ id: SHARED_ERROR_ID,
2523
+ label: 'Needs fix — shared key across apps',
2524
+ keys: sharedError.slice().sort((a, b) => a.key.localeCompare(b.key)),
2525
+ kind: 'error',
2526
+ })
2527
+ }
2528
+ if (trackingError.length) {
2529
+ sections.push({
2530
+ id: TRACKING_ERROR_ID,
2531
+ label: 'Needs fix — account/usage not tracked',
2532
+ keys: trackingError.slice().sort((a, b) => a.key.localeCompare(b.key)),
2533
+ kind: 'error',
2534
+ })
2535
+ }
2241
2536
  if (multiError.length) {
2242
2537
  sections.push({
2243
2538
  id: MULTI_ERROR_ID,
@@ -2298,7 +2593,10 @@ function SecretKeyRow({
2298
2593
  border: `1px solid ${selected ? 'var(--border-accent)' : 'transparent'}`,
2299
2594
  background: selected
2300
2595
  ? 'rgba(59,130,246,0.14)'
2301
- : k.multi_repo_error || k.rotation_overdue
2596
+ : k.multi_repo_error ||
2597
+ k.rotation_overdue ||
2598
+ (k.shared_value && k.shared_value_severity === 'error') ||
2599
+ (k.tracking_status && k.tracking_status !== 'ok' && k.tracking_status !== 'exempt' && !k.tracking_exempt)
2302
2600
  ? 'rgba(248,113,113,0.06)'
2303
2601
  : 'rgba(148,163,184,0.04)',
2304
2602
  color: 'inherit',
@@ -2318,6 +2616,18 @@ function SecretKeyRow({
2318
2616
  }}
2319
2617
  >
2320
2618
  {k.key}
2619
+ {k.shared_value && k.shared_value_severity === 'error' && (
2620
+ <span style={{ marginLeft: 6, fontSize: 9, color: '#fb923c' }}>SHARED</span>
2621
+ )}
2622
+ {k.tracking_status &&
2623
+ k.tracking_status !== 'ok' &&
2624
+ k.tracking_status !== 'exempt' &&
2625
+ !k.tracking_exempt && (
2626
+ <span style={{ marginLeft: 6, fontSize: 9, color: '#f87171' }}>TRACK</span>
2627
+ )}
2628
+ {k.tracking_status === 'ok' && (
2629
+ <span style={{ marginLeft: 6, fontSize: 9, color: '#34d399' }}>OK</span>
2630
+ )}
2321
2631
  {k.multi_repo_error && (
2322
2632
  <span style={{ marginLeft: 6, fontSize: 9, color: '#f87171' }}>MULTI</span>
2323
2633
  )}
@@ -2337,6 +2647,9 @@ function SecretKeyRow({
2337
2647
  {groupBy === 'api'
2338
2648
  ? (k.repos?.length ? k.repos.join(', ') : 'Developer')
2339
2649
  : (k.provider_name || k.provider || '—')} · {k.masked || '••••'}
2650
+ {k.shared_value && k.shared_apps?.length
2651
+ ? ` · apps: ${k.shared_apps.join(', ')}`
2652
+ : ''}
2340
2653
  {k.multi_repo_error ? ` · ${k.repos.join(', ')}` : ''}
2341
2654
  </div>
2342
2655
  </div>
@@ -10,18 +10,26 @@ describe('SettingsPage', () => {
10
10
  vi.resetAllMocks();
11
11
  });
12
12
 
13
- it('renders without crashing', async () => {
13
+ it('renders System Settings when config loads', async () => {
14
14
  vi.spyOn(console, 'error').mockImplementation(() => {});
15
-
15
+
16
16
  vi.mocked(api.fetchConfigJson).mockResolvedValue({
17
17
  security: { bind: {}, network: {}, rate_limits: {}, sandbox: {}, dashboard: {}, api: {} },
18
18
  budget: { budget: {} },
19
19
  brain: {},
20
- secrets: {}
20
+ secrets: {},
21
21
  } as any);
22
- vi.mocked(api.fetchHealth).mockResolvedValue({ status: 'healthy', version: '1.0.0' } as any);
23
- vi.mocked(api.checkUpdate).mockResolvedValue({ updateAvailable: false } as any);
24
- vi.mocked(api.fetchBrains).mockResolvedValue([]);
22
+ vi.mocked(api.fetchHealth).mockResolvedValue({
23
+ status: 'healthy',
24
+ version: '1.0.0',
25
+ cli_agents: ['claude'],
26
+ } as any);
27
+ vi.mocked(api.checkUpdate).mockResolvedValue({
28
+ updateAvailable: false,
29
+ currentVersion: '1.0.0',
30
+ latestVersion: '1.0.0',
31
+ } as any);
32
+ vi.mocked(api.triggerRecompile).mockResolvedValue({ success: true, message: 'ok' } as any);
25
33
 
26
34
  await act(async () => {
27
35
  render(<SettingsPage />);
@@ -29,5 +37,21 @@ describe('SettingsPage', () => {
29
37
 
30
38
  const systemSettings = await screen.findByText(/System Settings/i, {}, { timeout: 4000 });
31
39
  expect(systemSettings).toBeInTheDocument();
40
+ expect(screen.getByTestId('settings-page')).toBeInTheDocument();
41
+ }, 10000);
42
+
43
+ it('shows error + retry when config fails', async () => {
44
+ vi.spyOn(console, 'error').mockImplementation(() => {});
45
+ vi.mocked(api.fetchConfigJson).mockRejectedValue(new Error('Config JSON API error: 500'));
46
+ vi.mocked(api.fetchHealth).mockResolvedValue({ status: 'healthy' } as any);
47
+ vi.mocked(api.checkUpdate).mockResolvedValue({ updateAvailable: false } as any);
48
+ vi.mocked(api.fetchBrains).mockResolvedValue([]);
49
+
50
+ await act(async () => {
51
+ render(<SettingsPage />);
52
+ });
53
+
54
+ expect(await screen.findByTestId('settings-error')).toBeInTheDocument();
55
+ expect(screen.getByText(/Config JSON API error: 500/i)).toBeInTheDocument();
32
56
  }, 10000);
33
57
  });