Skip to content

Commit 80e1c3d

Browse files
easelclaude
andcommitted
feat(audit): intent ID filters, deep links, and lineage panel [axon-ca7e43df]
- /audit page reads `?intent=<id>` URL param to pre-populate the intent ID filter and show a contextual banner with a link to intent detail - intent-filtered queries use the REST fetchIntentAudit endpoint which returns full intent_lineage metadata for each entry - detail panel shows an Intent Lineage section (decision, policy/schema version, approver, reason, origin) with a deep link to intent detail - intent detail "Open audit log" link now carries `?intent=<id>` so the user can return to the filtered audit context - entries loaded via the REST endpoint are already in ascending audit_id order, giving chronological preview → approve/reject/expire/commit view - five new tests in intent-audit-lineage.spec.ts cover deep-link pre-population, lineage panel, back navigation, and chronological order for both committed and approved/rejected intents Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7ecc22e commit 80e1c3d

3 files changed

Lines changed: 265 additions & 24 deletions

File tree

ui/src/routes/tenants/[tenant]/databases/[database]/audit/+page.svelte

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
<script lang="ts">
2+
import { base } from '$app/paths';
3+
import { page } from '$app/state';
24
import {
35
type AuditEntry,
46
type EffectiveCollectionPolicy,
57
fetchAudit,
68
fetchEffectivePolicy,
9+
fetchIntentAudit,
710
revertAuditEntry,
811
rollbackTransaction,
912
} from '$lib/api';
@@ -61,6 +64,7 @@ type AuditFilters = {
6164
actor: string;
6265
startDate: string;
6366
endDate: string;
67+
intentId: string;
6468
};
6569
6670
let entries = $state<AuditEntry[]>([]);
@@ -71,6 +75,7 @@ const filters = $state<AuditFilters>({
7175
actor: '',
7276
startDate: '',
7377
endDate: '',
78+
intentId: page.url.searchParams.get('intent') ?? '',
7479
});
7580
let selectedEntry = $state<AuditEntry | null>(null);
7681
@@ -84,6 +89,14 @@ let txRollbackConfirming = $state(false);
8489
let txRollbackMessage = $state<string | null>(null);
8590
let txRollbackError = $state<unknown>(null);
8691
92+
const basePath = $derived(
93+
`${base}/tenants/${encodeURIComponent(scope.tenant)}/databases/${encodeURIComponent(scope.database)}`,
94+
);
95+
96+
function intentDetailHref(intentId: string): string {
97+
return `${basePath}/intents/${encodeURIComponent(intentId)}`;
98+
}
99+
87100
function txSiblingCount(txId: string | number | null | undefined): number {
88101
if (!txId) return 0;
89102
return entries.filter((e) => e.transaction_id === txId).length;
@@ -149,30 +162,36 @@ function formatTimestamp(timestampNs: number): string {
149162
async function loadEntries() {
150163
loading = true;
151164
try {
152-
const auditFilters: {
153-
collection?: string;
154-
actor?: string;
155-
sinceNs?: string;
156-
untilNs?: string;
157-
} = {};
165+
if (filters.intentId && scope) {
166+
// Use the REST intent-audit endpoint which returns full intent_lineage data.
167+
const response = await fetchIntentAudit(filters.intentId, scope);
168+
entries = response.entries;
169+
} else {
170+
const auditFilters: {
171+
collection?: string;
172+
actor?: string;
173+
sinceNs?: string;
174+
untilNs?: string;
175+
} = {};
158176
159-
if (filters.collection) {
160-
auditFilters.collection = filters.collection;
161-
}
162-
if (filters.actor) {
163-
auditFilters.actor = filters.actor;
164-
}
165-
const sinceNs = dateToNs(filters.startDate);
166-
if (sinceNs) {
167-
auditFilters.sinceNs = sinceNs;
168-
}
169-
const untilNs = dateToNs(filters.endDate, true);
170-
if (untilNs) {
171-
auditFilters.untilNs = untilNs;
172-
}
177+
if (filters.collection) {
178+
auditFilters.collection = filters.collection;
179+
}
180+
if (filters.actor) {
181+
auditFilters.actor = filters.actor;
182+
}
183+
const sinceNs = dateToNs(filters.startDate);
184+
if (sinceNs) {
185+
auditFilters.sinceNs = sinceNs;
186+
}
187+
const untilNs = dateToNs(filters.endDate, true);
188+
if (untilNs) {
189+
auditFilters.untilNs = untilNs;
190+
}
173191
174-
const response = await fetchAudit(auditFilters, scope);
175-
entries = response.entries;
192+
const response = await fetchAudit(auditFilters, scope);
193+
entries = response.entries;
194+
}
176195
selectedEntry = entries[0] ?? null;
177196
// Mirror selectEntry()'s policy lookup so the auto-selected first
178197
// row's before/after payloads render with the correct redaction
@@ -203,6 +222,23 @@ $effect(() => {
203222
</div>
204223
</div>
205224

225+
{#if filters.intentId}
226+
<div class="message" data-testid="audit-intent-banner">
227+
Filtered by intent <code>{filters.intentId}</code>
228+
<a class="inline-link" href={intentDetailHref(filters.intentId)} data-testid="audit-intent-detail-link">
229+
View intent detail
230+
</a>
231+
<button
232+
onclick={() => {
233+
filters.intentId = '';
234+
void loadEntries();
235+
}}
236+
>
237+
Clear filter
238+
</button>
239+
</div>
240+
{/if}
241+
206242
<section class="panel">
207243
<div class="panel-body stack">
208244
<div class="two-column">
@@ -222,6 +258,14 @@ $effect(() => {
222258
<span>Until</span>
223259
<input type="date" bind:value={filters.endDate} />
224260
</label>
261+
<label>
262+
<span>Intent ID</span>
263+
<input
264+
bind:value={filters.intentId}
265+
placeholder="Filter by intent ID"
266+
data-testid="audit-intent-filter"
267+
/>
268+
</label>
225269
</div>
226270
<div class="actions">
227271
<button class="primary" onclick={() => loadEntries()}>Apply Filters</button>
@@ -258,7 +302,11 @@ $effect(() => {
258302
</thead>
259303
<tbody>
260304
{#each entries as entry}
261-
<tr onclick={() => selectEntry(entry)}>
305+
<tr
306+
onclick={() => selectEntry(entry)}
307+
data-testid="audit-entry-row"
308+
data-intent-id={entry.intent_lineage?.intent_id ?? null}
309+
>
262310
<td>{entry.id}</td>
263311
<td>{formatTimestamp(entry.timestamp_ns)}</td>
264312
<td>{entry.collection}</td>
@@ -268,6 +316,9 @@ $effect(() => {
268316
{#if entry.transaction_id}
269317
<span class="pill">tx #{String(entry.transaction_id).substring(0, 8)}</span>
270318
{/if}
319+
{#if entry.intent_lineage?.intent_id}
320+
<span class="pill" data-testid="audit-entry-intent-pill">intent</span>
321+
{/if}
271322
</td>
272323
<td>{entry.actor ?? 'system'}</td>
273324
</tr>
@@ -343,6 +394,45 @@ $effect(() => {
343394
{/if}
344395
</div>
345396
{/if}
397+
{#if selectedEntry.intent_lineage}
398+
<div data-testid="audit-intent-lineage">
399+
<h3>Intent Lineage</h3>
400+
<div class="meta-grid">
401+
<span>Intent ID</span>
402+
<a
403+
class="inline-link"
404+
href={intentDetailHref(selectedEntry.intent_lineage.intent_id)}
405+
data-testid="audit-intent-link"
406+
>
407+
{selectedEntry.intent_lineage.intent_id}
408+
</a>
409+
<span>Decision</span>
410+
<strong data-testid="audit-lineage-decision">{selectedEntry.intent_lineage.decision}</strong>
411+
<span>Policy version</span>
412+
<strong data-testid="audit-lineage-policy-version">{selectedEntry.intent_lineage.policy_version}</strong>
413+
<span>Schema version</span>
414+
<strong>{selectedEntry.intent_lineage.schema_version}</strong>
415+
{#if selectedEntry.intent_lineage.approver?.actor ?? selectedEntry.intent_lineage.approver?.user_id}
416+
<span>Approver</span>
417+
<code data-testid="audit-lineage-approver">
418+
{selectedEntry.intent_lineage.approver?.actor ?? selectedEntry.intent_lineage.approver?.user_id}
419+
</code>
420+
{/if}
421+
{#if selectedEntry.intent_lineage.reason}
422+
<span>Reason</span>
423+
<span data-testid="audit-lineage-reason">{selectedEntry.intent_lineage.reason}</span>
424+
{/if}
425+
{#if selectedEntry.intent_lineage.origin}
426+
<span>Origin</span>
427+
<code data-testid="audit-lineage-origin">
428+
{[selectedEntry.intent_lineage.origin.surface, selectedEntry.intent_lineage.origin.tool_name]
429+
.filter((v) => v && v.length > 0)
430+
.join(': ')}
431+
</code>
432+
{/if}
433+
</div>
434+
</div>
435+
{/if}
346436
<div>
347437
<h3>Before</h3>
348438
<pre>{safeJson(selectedEntry.data_before, selectedEntry.collection)}</pre>

ui/src/routes/tenants/[tenant]/databases/[database]/intents/[intent]/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const basePath = $derived(
4141
`${base}/tenants/${encodeURIComponent(scope.tenant)}/databases/${encodeURIComponent(scope.database)}`,
4242
);
4343
const inboxHref = $derived(`${basePath}/intents`);
44-
const auditHref = $derived(`${basePath}/audit`);
44+
const auditHref = $derived(`${basePath}/audit?intent=${encodeURIComponent(intentId)}`);
4545
4646
let intent = $state<MutationIntent | null>(null);
4747
let auditEntries = $state<AuditEntry[]>([]);

ui/tests/e2e/intent-audit-lineage.spec.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import {
55
TASK_COLLECTION,
66
activateProposedPolicy,
77
approveIntent,
8+
commitIntent,
89
createBudgetRecord,
10+
dbAuditUrl,
911
dbIntentUrl,
1012
dbIntentsUrl,
1113
graphqlPath,
@@ -143,3 +145,152 @@ test.describe('Intent audit lineage', () => {
143145
expect(metadata.new_policy_version).toBe('2');
144146
});
145147
});
148+
149+
test.describe('Intent audit deep link and lineage panel', () => {
150+
test('deep link filters /audit by intent ID and pre-populates filter', async ({
151+
page,
152+
request,
153+
}) => {
154+
const db = await seedApprovalCollections(request, 'audit-deeplink');
155+
await createBudgetRecord(request, db, TASK_COLLECTION, 'task-dl');
156+
const intent = await previewBudgetIntent(request, db, 'task-dl', 6_000, {
157+
agentId: 'tool.commit-dl',
158+
});
159+
await commitIntent(request, db, intent);
160+
161+
await routeGraphqlAs(page, 'finance-agent');
162+
await page.goto(`${dbAuditUrl(db)}?intent=${encodeURIComponent(intent.intentId)}`);
163+
164+
// Filter field pre-populated from URL param
165+
await expect(page.getByTestId('audit-intent-filter')).toHaveValue(intent.intentId);
166+
167+
// Banner shows the filtered intent ID
168+
await expect(page.getByTestId('audit-intent-banner')).toBeVisible();
169+
await expect(page.getByTestId('audit-intent-banner')).toContainText(intent.intentId);
170+
171+
// Entries are present; preview comes before commit (chronological order)
172+
await expect(page.locator('[data-testid="audit-entry-row"]').first()).toBeVisible();
173+
const operations = await page
174+
.locator('[data-testid="audit-entry-row"] td:nth-child(5)')
175+
.allTextContents();
176+
const previewIdx = operations.findIndex((op) => op.includes('mutation_intent.preview'));
177+
const commitIdx = operations.findIndex((op) => op.includes('intent.commit'));
178+
expect(previewIdx).toBeGreaterThanOrEqual(0);
179+
expect(commitIdx).toBeGreaterThan(previewIdx);
180+
});
181+
182+
test('shows intent lineage panel when an intent audit entry is selected', async ({
183+
page,
184+
request,
185+
}) => {
186+
const db = await seedApprovalCollections(request, 'audit-lineage-panel');
187+
await createBudgetRecord(request, db, TASK_COLLECTION, 'task-lp');
188+
const intent = await previewBudgetIntent(request, db, 'task-lp', 6_000, {
189+
agentId: 'tool.panel-test',
190+
});
191+
await commitIntent(request, db, intent);
192+
193+
await routeGraphqlAs(page, 'finance-agent');
194+
await page.goto(`${dbAuditUrl(db)}?intent=${encodeURIComponent(intent.intentId)}`);
195+
196+
// Click the commit row to select it
197+
const commitRow = page.locator('[data-testid="audit-entry-row"]', {
198+
has: page.locator('td', { hasText: 'intent.commit' }),
199+
});
200+
await expect(commitRow).toBeVisible();
201+
await commitRow.click();
202+
203+
// Lineage panel is visible in the detail section
204+
await expect(page.getByTestId('audit-intent-lineage')).toBeVisible();
205+
206+
// Intent link navigates to the correct intent detail page
207+
const intentLink = page.getByTestId('audit-intent-link');
208+
await expect(intentLink).toBeVisible();
209+
await expect(intentLink).toContainText(intent.intentId);
210+
const href = await intentLink.getAttribute('href');
211+
expect(href).toContain(encodeURIComponent(intent.intentId));
212+
});
213+
214+
test('intent detail "open audit log" links to filtered audit view and back', async ({
215+
page,
216+
request,
217+
}) => {
218+
const db = await seedApprovalCollections(request, 'audit-backlink');
219+
await createBudgetRecord(request, db, TASK_COLLECTION, 'task-back');
220+
const intent = await previewBudgetIntent(request, db, 'task-back', 6_000);
221+
await commitIntent(request, db, intent);
222+
223+
await routeGraphqlAs(page, 'finance-agent');
224+
await page.goto(dbIntentUrl(db, intent.intentId));
225+
226+
// The "Open audit log" link should carry the intent ID as a filter param
227+
const auditLink = page.getByRole('link', { name: 'Open audit log' });
228+
await expect(auditLink).toBeVisible();
229+
const href = await auditLink.getAttribute('href');
230+
expect(href).toMatch(/\/audit/);
231+
expect(href).toContain(encodeURIComponent(intent.intentId));
232+
233+
// Click through; the audit page should show the filtered view
234+
await auditLink.click();
235+
await expect(page).toHaveURL(new RegExp('/audit'));
236+
await expect(page.getByTestId('audit-intent-filter')).toHaveValue(intent.intentId);
237+
await expect(page.getByTestId('audit-intent-banner')).toBeVisible();
238+
239+
// Clearing the intent filter dismisses the banner
240+
await page.getByTestId('audit-intent-banner').getByRole('button', { name: 'Clear filter' }).click();
241+
await expect(page.getByTestId('audit-intent-banner')).not.toBeVisible();
242+
});
243+
244+
test('shows preview and approval events for approved intent in chronological order', async ({
245+
page,
246+
request,
247+
}) => {
248+
const db = await seedApprovalCollections(request, 'audit-order-approve');
249+
await createBudgetRecord(request, db, TASK_COLLECTION, 'task-order');
250+
const intent = await previewBudgetIntent(request, db, 'task-order', 20_001, {
251+
agentId: 'tool.order-test',
252+
});
253+
await approveIntent(request, db, intent.intentId);
254+
255+
await routeGraphqlAs(page, 'finance-agent');
256+
await page.goto(`${dbAuditUrl(db)}?intent=${encodeURIComponent(intent.intentId)}`);
257+
258+
await expect(page.locator('[data-testid="audit-entry-row"]').first()).toBeVisible();
259+
const operations = await page
260+
.locator('[data-testid="audit-entry-row"] td:nth-child(5)')
261+
.allTextContents();
262+
const previewIdx = operations.findIndex((op) => op.includes('mutation_intent.preview'));
263+
const approveIdx = operations.findIndex((op) => op.includes('intent.approve'));
264+
expect(previewIdx).toBeGreaterThanOrEqual(0);
265+
expect(approveIdx).toBeGreaterThan(previewIdx);
266+
});
267+
268+
test('shows rejection event for rejected intent in chronological order', async ({
269+
page,
270+
request,
271+
}) => {
272+
const db = await seedApprovalCollections(request, 'audit-reject-lineage');
273+
const ids = await seedIntentStates(request, db);
274+
275+
await routeGraphqlAs(page, 'finance-agent');
276+
await page.goto(`${dbAuditUrl(db)}?intent=${encodeURIComponent(ids.rejected)}`);
277+
278+
await expect(page.locator('[data-testid="audit-entry-row"]').first()).toBeVisible();
279+
const operations = await page
280+
.locator('[data-testid="audit-entry-row"] td:nth-child(5)')
281+
.allTextContents();
282+
const previewIdx = operations.findIndex((op) => op.includes('mutation_intent.preview'));
283+
const rejectIdx = operations.findIndex((op) => op.includes('intent.reject'));
284+
expect(previewIdx).toBeGreaterThanOrEqual(0);
285+
expect(rejectIdx).toBeGreaterThan(previewIdx);
286+
287+
// Select the reject entry and verify lineage panel shows the intent link
288+
const rejectRow = page.locator('[data-testid="audit-entry-row"]', {
289+
has: page.locator('td', { hasText: 'intent.reject' }),
290+
});
291+
await rejectRow.click();
292+
await expect(page.getByTestId('audit-intent-lineage')).toBeVisible();
293+
await expect(page.getByTestId('audit-intent-link')).toBeVisible();
294+
await expect(page.getByTestId('audit-lineage-decision')).toContainText('needs_approval');
295+
});
296+
});

0 commit comments

Comments
 (0)