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.
- package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/config.ts +16 -2
- package/frontend/src/api/keys.ts +111 -0
- package/frontend/src/api/notifications.ts +54 -6
- package/frontend/src/api/update.ts +41 -5
- package/frontend/src/components/BrainSelector.spec.tsx +71 -47
- package/frontend/src/components/BrainSelector.tsx +112 -35
- package/frontend/src/components/MobilePairing.spec.tsx +44 -14
- package/frontend/src/components/MobilePairing.tsx +229 -22
- package/frontend/src/pages/HealthPage.tsx +12 -16
- package/frontend/src/pages/NotificationsPage.tsx +6 -3
- package/frontend/src/pages/RulesPage.spec.tsx +46 -15
- package/frontend/src/pages/RulesPage.tsx +41 -9
- package/frontend/src/pages/SecretsPage.tsx +316 -3
- package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
- package/frontend/src/pages/SettingsPage.tsx +228 -86
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +17 -2
- package/src/cli/chat.mjs +1 -1
- package/src/cli/daemon.mjs +2 -1
- package/src/cli/dream.mjs +14 -10
- package/src/cli/forget.mjs +14 -2
- package/src/cli/friction.mjs +1 -1
- package/src/cli/import-rules.mjs +8 -3
- package/src/cli/ingest.mjs +3 -2
- package/src/cli/lint.mjs +9 -6
- package/src/cli/rebuild.mjs +9 -4
- package/src/cli/remember.mjs +21 -15
- package/src/cli/secret.mjs +184 -4
- package/src/cli/share.mjs +5 -3
- package/src/cli/skill-projection.mjs +21 -2
- package/src/cli/snapshot.mjs +10 -8
- package/src/cli/update.mjs +2 -1
- package/src/core/clarity-rewriter.mjs +31 -12
- package/src/core/config.mjs +10 -4
- package/src/core/daemon-loop.mjs +33 -6
- package/src/core/embeddings.mjs +51 -3
- package/src/core/memory-title.mjs +70 -0
- package/src/core/notifications.mjs +204 -34
- package/src/core/package-auto-update.mjs +99 -3
- package/src/core/pairing.mjs +168 -0
- package/src/core/provider-account-sync.mjs +1145 -0
- package/src/core/provider-catalog.mjs +11 -4
- package/src/core/research.mjs +98 -24
- package/src/core/runtime.mjs +11 -1
- package/src/core/scheduler.mjs +31 -9
- package/src/core/schema.mjs +23 -0
- package/src/core/secret-integration-research.mjs +325 -0
- package/src/core/secrets-store.mjs +284 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/skills-registry.mjs +12 -0
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/surface.mjs +4 -4
- package/src/core/task-executors.mjs +161 -14
- package/src/core/throttled-fetch.mjs +5 -0
- package/src/core/vault.mjs +7 -1
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/index.mjs +56 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +40 -0
- package/src/server/routes/_shared.mjs +33 -1
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/context.mjs +14 -9
- package/src/server/routes/dashboard.mjs +39 -18
- package/src/server/routes/docs.mjs +27 -13
- package/src/server/routes/dream.mjs +10 -11
- package/src/server/routes/export.mjs +43 -12
- package/src/server/routes/field.mjs +11 -9
- package/src/server/routes/graph.mjs +31 -13
- package/src/server/routes/import.mjs +35 -16
- package/src/server/routes/instructions.mjs +41 -5
- package/src/server/routes/memory.mjs +61 -21
- package/src/server/routes/mesh.mjs +2 -1
- package/src/server/routes/notifications.mjs +103 -0
- package/src/server/routes/rules.mjs +75 -20
- package/src/server/routes/secrets.mjs +110 -4
- package/src/server/routes/share.mjs +2 -1
- package/src/server/routes/ssss.mjs +11 -3
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +33 -7
- 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
|
-
|
|
5
|
+
const apiFetch = vi.fn();
|
|
6
|
+
const getApiBase = vi.fn().mockReturnValue('');
|
|
7
|
+
|
|
6
8
|
vi.mock('../api', () => ({
|
|
7
|
-
apiFetch:
|
|
8
|
-
getApiBase:
|
|
9
|
+
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
|
10
|
+
getApiBase: () => getApiBase(),
|
|
9
11
|
}));
|
|
10
12
|
|
|
11
13
|
describe('RulesPage Component', () => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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(
|
|
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 ||
|
|
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
|
|
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({
|
|
23
|
-
|
|
24
|
-
|
|
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
|
});
|