@frockbot/plugin-audit 0.0.0 → 0.1.1
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/frockbot.json +37 -0
- package/package.json +43 -6
- package/src/backend.test.ts +193 -0
- package/src/backend.ts +148 -0
- package/src/bot.test.ts +250 -0
- package/src/bot.ts +312 -0
- package/src/classify.test.ts +174 -0
- package/src/classify.ts +151 -0
- package/src/client/AuditSection.vue +363 -0
- package/src/client/index.test.ts +160 -0
- package/src/client/index.ts +125 -0
- package/src/client/state.ts +35 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +3 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +110 -0
- package/src/shared.ts +623 -0
- package/src/store.test.ts +168 -0
- package/src/store.ts +432 -0
- package/src/testing.ts +197 -0
- package/src/user.test.ts +139 -0
- package/src/user.ts +215 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The Bot's audit log: every audited effect it has performed, newest first.
|
|
3
|
+
//
|
|
4
|
+
// It renders durable state and decides nothing. In particular it never infers
|
|
5
|
+
// an outcome: an effect the durable event log cannot explain is shown as
|
|
6
|
+
// "Unknown" in the same place a success or a failure would be, because that is
|
|
7
|
+
// what the log says and hiding it would be the silent classification the
|
|
8
|
+
// reconciliation rule forbids. The same goes for truncation — a table trimmed
|
|
9
|
+
// to its retention bound says so above the rows rather than quietly answering
|
|
10
|
+
// with fewer.
|
|
11
|
+
import { UiAnchor, UiButton, UiIcon } from "@frockbot/client-ui";
|
|
12
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
13
|
+
import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
|
|
14
|
+
import { computed, inject, watch } from "vue";
|
|
15
|
+
import {
|
|
16
|
+
AUDIT_KINDS_V1,
|
|
17
|
+
type AuditEntryV1,
|
|
18
|
+
type AuditKindV1,
|
|
19
|
+
} from "../shared.js";
|
|
20
|
+
import { auditStateKey } from "./state.js";
|
|
21
|
+
|
|
22
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
23
|
+
const providedState = inject(auditStateKey);
|
|
24
|
+
if (!providedWeb || !providedState) {
|
|
25
|
+
throw new Error("Audit client services were not provided");
|
|
26
|
+
}
|
|
27
|
+
const web = providedWeb;
|
|
28
|
+
const audit = providedState;
|
|
29
|
+
|
|
30
|
+
const botId = computed(() => web.value.activeBotId);
|
|
31
|
+
const anchorHref = computed(() =>
|
|
32
|
+
settingsLinkV1({ anchor: "bot-audit", botId: botId.value }),
|
|
33
|
+
);
|
|
34
|
+
const kinds = AUDIT_KINDS_V1;
|
|
35
|
+
|
|
36
|
+
watch(
|
|
37
|
+
botId,
|
|
38
|
+
(id) => {
|
|
39
|
+
if (!id) return;
|
|
40
|
+
if (audit.value.botId !== id || !audit.value.loaded) {
|
|
41
|
+
void audit.value.load(id);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{ immediate: true },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function selectKind(kind: AuditKindV1 | undefined): void {
|
|
48
|
+
const id = botId.value;
|
|
49
|
+
if (!id) return;
|
|
50
|
+
void audit.value.load(id, {
|
|
51
|
+
...audit.value.filters,
|
|
52
|
+
...(kind === undefined ? { kind: undefined } : { kind }),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where the effect ran, in words rather than in the wire shape. */
|
|
57
|
+
function target(entry: AuditEntryV1): string {
|
|
58
|
+
if (entry.target === "computer") return "This Computer";
|
|
59
|
+
if (entry.target.startsWith("machine:")) {
|
|
60
|
+
return `Machine ${entry.target.slice("machine:".length)}`;
|
|
61
|
+
}
|
|
62
|
+
return entry.target.slice("remote:".length);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function when(entry: AuditEntryV1): string {
|
|
66
|
+
const parsed = Date.parse(entry.at);
|
|
67
|
+
return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : entry.at;
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<UiAnchor
|
|
73
|
+
as="section"
|
|
74
|
+
anchor="bot-audit"
|
|
75
|
+
label="Audit log"
|
|
76
|
+
:href="anchorHref"
|
|
77
|
+
class="audit"
|
|
78
|
+
>
|
|
79
|
+
<header class="audit__header">
|
|
80
|
+
<span class="audit__icon" aria-hidden="true"
|
|
81
|
+
><UiIcon name="history"
|
|
82
|
+
/></span>
|
|
83
|
+
<span class="audit__intro">
|
|
84
|
+
<strong>Audit log</strong>
|
|
85
|
+
<small>
|
|
86
|
+
Every shell command, browser action, remote tool call and Workspace
|
|
87
|
+
write this Bot has made. Arguments are never kept — only a digest and
|
|
88
|
+
a redacted preview.
|
|
89
|
+
</small>
|
|
90
|
+
</span>
|
|
91
|
+
<UiButton
|
|
92
|
+
type="button"
|
|
93
|
+
:disabled="!botId || audit.busy"
|
|
94
|
+
@click="botId && audit.rebuild(botId)"
|
|
95
|
+
>
|
|
96
|
+
Rebuild
|
|
97
|
+
</UiButton>
|
|
98
|
+
</header>
|
|
99
|
+
|
|
100
|
+
<p v-if="audit.error" class="audit__error" role="alert">
|
|
101
|
+
{{ audit.error }}
|
|
102
|
+
</p>
|
|
103
|
+
|
|
104
|
+
<p
|
|
105
|
+
v-if="audit.indexState === 'truncated'"
|
|
106
|
+
class="audit__banner"
|
|
107
|
+
role="status"
|
|
108
|
+
>
|
|
109
|
+
Older activity has been trimmed to this account's retention bound — 20 000
|
|
110
|
+
entries, or 180 days. Rebuilding restores everything the Bots' own turns
|
|
111
|
+
still hold.
|
|
112
|
+
</p>
|
|
113
|
+
<p
|
|
114
|
+
v-else-if="audit.indexState === 'rebuilding'"
|
|
115
|
+
class="audit__banner"
|
|
116
|
+
role="status"
|
|
117
|
+
>
|
|
118
|
+
A rebuild is in progress. What is shown may be incomplete until it
|
|
119
|
+
finishes.
|
|
120
|
+
</p>
|
|
121
|
+
|
|
122
|
+
<div v-if="audit.receipt" class="audit-receipt">
|
|
123
|
+
<strong>
|
|
124
|
+
Rebuilt {{ audit.receipt.entries }} entries across
|
|
125
|
+
{{ audit.receipt.bots }} Bots
|
|
126
|
+
</strong>
|
|
127
|
+
<small>
|
|
128
|
+
{{ audit.receipt.unknownOutcomes }} with an outcome the turn log cannot
|
|
129
|
+
explain, and {{ audit.receipt.hostJournalDiscrepancies }} effect(s) the
|
|
130
|
+
Computer host reported that no turn accounts for. A discrepancy is
|
|
131
|
+
counted here, never written in as if a turn had recorded it.
|
|
132
|
+
</small>
|
|
133
|
+
<div class="audit-receipt__actions">
|
|
134
|
+
<UiButton type="button" @click="audit.dismissReceipt()">
|
|
135
|
+
Done
|
|
136
|
+
</UiButton>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<div class="audit__chips" role="group" aria-label="Filter by kind">
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
class="audit-chip"
|
|
144
|
+
:data-active="audit.filters.kind === undefined ? 'yes' : 'no'"
|
|
145
|
+
@click="selectKind(undefined)"
|
|
146
|
+
>
|
|
147
|
+
All
|
|
148
|
+
</button>
|
|
149
|
+
<button
|
|
150
|
+
v-for="kind in kinds"
|
|
151
|
+
:key="kind"
|
|
152
|
+
type="button"
|
|
153
|
+
class="audit-chip"
|
|
154
|
+
:data-active="audit.filters.kind === kind ? 'yes' : 'no'"
|
|
155
|
+
@click="selectKind(kind)"
|
|
156
|
+
>
|
|
157
|
+
{{ kind }}
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<p v-if="audit.loaded && audit.entries.length === 0" class="audit__empty">
|
|
162
|
+
No audited activity yet. A turn that only talks records nothing here.
|
|
163
|
+
</p>
|
|
164
|
+
|
|
165
|
+
<ol v-else class="audit__rows">
|
|
166
|
+
<li
|
|
167
|
+
v-for="entry in audit.entries"
|
|
168
|
+
:key="`${entry.runId}:${entry.occurrenceId}`"
|
|
169
|
+
class="audit-row"
|
|
170
|
+
>
|
|
171
|
+
<span class="audit-row__time">{{ when(entry) }}</span>
|
|
172
|
+
<span class="audit-row__kind" :data-kind="entry.kind">{{
|
|
173
|
+
entry.kind
|
|
174
|
+
}}</span>
|
|
175
|
+
<span class="audit-row__target">{{ target(entry) }}</span>
|
|
176
|
+
<span class="audit-row__preview" :title="entry.toolName">{{
|
|
177
|
+
entry.preview
|
|
178
|
+
}}</span>
|
|
179
|
+
<span class="audit-row__outcome" :data-outcome="entry.outcome">{{
|
|
180
|
+
entry.outcome
|
|
181
|
+
}}</span>
|
|
182
|
+
</li>
|
|
183
|
+
</ol>
|
|
184
|
+
|
|
185
|
+
<div v-if="audit.nextCursor" class="audit__more">
|
|
186
|
+
<UiButton
|
|
187
|
+
type="button"
|
|
188
|
+
:disabled="audit.busy"
|
|
189
|
+
@click="botId && audit.loadMore(botId)"
|
|
190
|
+
>
|
|
191
|
+
Load more
|
|
192
|
+
</UiButton>
|
|
193
|
+
<small>{{ audit.entries.length }} of {{ audit.total }}</small>
|
|
194
|
+
</div>
|
|
195
|
+
</UiAnchor>
|
|
196
|
+
</template>
|
|
197
|
+
|
|
198
|
+
<style scoped>
|
|
199
|
+
.audit {
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
gap: 12px;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.audit__header {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
gap: 10px;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.audit__icon {
|
|
212
|
+
display: grid;
|
|
213
|
+
width: var(--frock-avatar-sm);
|
|
214
|
+
height: var(--frock-avatar-sm);
|
|
215
|
+
flex: 0 0 auto;
|
|
216
|
+
place-items: center;
|
|
217
|
+
border-radius: 8px;
|
|
218
|
+
color: var(--frock-action-primary);
|
|
219
|
+
background: var(--frock-surface);
|
|
220
|
+
box-shadow: inset 0 0 0 1px var(--frock-border);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.audit__intro {
|
|
224
|
+
display: flex;
|
|
225
|
+
min-width: 0;
|
|
226
|
+
flex: 1 1 auto;
|
|
227
|
+
flex-direction: column;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.audit__intro strong {
|
|
231
|
+
color: var(--frock-text);
|
|
232
|
+
font-size: var(--frock-text-md);
|
|
233
|
+
font-weight: 600;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.audit__intro small,
|
|
237
|
+
.audit__empty,
|
|
238
|
+
.audit__banner {
|
|
239
|
+
color: var(--frock-text-muted);
|
|
240
|
+
font-size: var(--frock-text-sm);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.audit__error {
|
|
244
|
+
color: var(--frock-danger-text);
|
|
245
|
+
font-size: var(--frock-text-sm);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.audit__banner {
|
|
249
|
+
border: 1px solid var(--frock-border);
|
|
250
|
+
border-radius: var(--frock-radius-card);
|
|
251
|
+
padding: 8px 10px;
|
|
252
|
+
background: var(--frock-surface-subtle);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.audit-receipt {
|
|
256
|
+
display: flex;
|
|
257
|
+
flex-direction: column;
|
|
258
|
+
gap: 6px;
|
|
259
|
+
border: 1px solid var(--frock-border);
|
|
260
|
+
border-radius: var(--frock-radius-card);
|
|
261
|
+
padding: 12px;
|
|
262
|
+
background: var(--frock-surface-subtle);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.audit-receipt strong {
|
|
266
|
+
color: var(--frock-text);
|
|
267
|
+
font-size: var(--frock-text-sm);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.audit-receipt small {
|
|
271
|
+
color: var(--frock-text-muted);
|
|
272
|
+
font-size: var(--frock-text-sm);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.audit-receipt__actions {
|
|
276
|
+
display: flex;
|
|
277
|
+
gap: 8px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.audit__chips {
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-wrap: wrap;
|
|
283
|
+
gap: 6px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.audit-chip {
|
|
287
|
+
border: 1px solid var(--frock-border);
|
|
288
|
+
border-radius: 999px;
|
|
289
|
+
padding: 3px 10px;
|
|
290
|
+
color: var(--frock-text-muted);
|
|
291
|
+
background: var(--frock-surface);
|
|
292
|
+
cursor: pointer;
|
|
293
|
+
font-size: var(--frock-text-sm);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
.audit-chip[data-active="yes"] {
|
|
297
|
+
color: var(--frock-action-primary);
|
|
298
|
+
border-color: var(--frock-action-primary);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.audit__rows {
|
|
302
|
+
display: flex;
|
|
303
|
+
flex-direction: column;
|
|
304
|
+
gap: 4px;
|
|
305
|
+
margin: 0;
|
|
306
|
+
padding: 0;
|
|
307
|
+
list-style: none;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.audit-row {
|
|
311
|
+
display: grid;
|
|
312
|
+
align-items: baseline;
|
|
313
|
+
gap: 8px;
|
|
314
|
+
grid-template-columns: auto auto auto 1fr auto;
|
|
315
|
+
border: 1px solid var(--frock-border);
|
|
316
|
+
border-radius: var(--frock-radius-card);
|
|
317
|
+
padding: 6px 10px;
|
|
318
|
+
background: var(--frock-surface-subtle);
|
|
319
|
+
font-size: var(--frock-text-sm);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.audit-row__time,
|
|
323
|
+
.audit-row__target {
|
|
324
|
+
color: var(--frock-text-muted);
|
|
325
|
+
white-space: nowrap;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.audit-row__kind {
|
|
329
|
+
border: 1px solid var(--frock-border);
|
|
330
|
+
border-radius: 999px;
|
|
331
|
+
padding: 1px 8px;
|
|
332
|
+
color: var(--frock-text);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.audit-row__preview {
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
color: var(--frock-text);
|
|
338
|
+
font-family: var(--frock-font-mono);
|
|
339
|
+
text-overflow: ellipsis;
|
|
340
|
+
white-space: nowrap;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.audit-row__outcome {
|
|
344
|
+
color: var(--frock-text-muted);
|
|
345
|
+
white-space: nowrap;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.audit-row__outcome[data-outcome="error"],
|
|
349
|
+
.audit-row__outcome[data-outcome="refused"] {
|
|
350
|
+
color: var(--frock-danger-text);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.audit__more {
|
|
354
|
+
display: flex;
|
|
355
|
+
align-items: center;
|
|
356
|
+
gap: 8px;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.audit__more small {
|
|
360
|
+
color: var(--frock-text-muted);
|
|
361
|
+
font-size: var(--frock-text-sm);
|
|
362
|
+
}
|
|
363
|
+
</style>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
ClientPluginContext,
|
|
4
|
+
ClientSlotRegistration,
|
|
5
|
+
} from "@frockbot/client-core";
|
|
6
|
+
import { auditClientPlugin } from "./index.js";
|
|
7
|
+
import { auditStateKey, type AuditClientState } from "./state.js";
|
|
8
|
+
|
|
9
|
+
const ENTRY = {
|
|
10
|
+
schemaVersion: 1,
|
|
11
|
+
botId: "scout",
|
|
12
|
+
runId: "run-1",
|
|
13
|
+
occurrenceId: "tool:1:1:0",
|
|
14
|
+
turn: 1,
|
|
15
|
+
step: 1,
|
|
16
|
+
ordinal: 0,
|
|
17
|
+
effectId: "tool:1:1:0",
|
|
18
|
+
at: "2026-08-31T00:00:00.000Z",
|
|
19
|
+
kind: "shell",
|
|
20
|
+
target: "computer",
|
|
21
|
+
toolName: "computer_exec",
|
|
22
|
+
argumentDigest: "a".repeat(64),
|
|
23
|
+
preview: "ls -la",
|
|
24
|
+
outcome: "ok",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function mount(
|
|
28
|
+
overrides: {
|
|
29
|
+
hostedRequest?: ClientPluginContext["transport"]["hostedRequest"];
|
|
30
|
+
} = {},
|
|
31
|
+
): {
|
|
32
|
+
state: { value: AuditClientState };
|
|
33
|
+
slots: ClientSlotRegistration[];
|
|
34
|
+
calls: Array<[string, string | undefined, string | undefined]>;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
} {
|
|
37
|
+
const slots: ClientSlotRegistration[] = [];
|
|
38
|
+
const calls: Array<[string, string | undefined, string | undefined]> = [];
|
|
39
|
+
let state: unknown;
|
|
40
|
+
const context: ClientPluginContext = {
|
|
41
|
+
transport: {
|
|
42
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
43
|
+
hostedRequest:
|
|
44
|
+
overrides.hostedRequest ??
|
|
45
|
+
((path, method, body) => {
|
|
46
|
+
calls.push([path, method, body]);
|
|
47
|
+
if (method === "POST") {
|
|
48
|
+
return Promise.resolve({
|
|
49
|
+
schemaVersion: 1,
|
|
50
|
+
status: "rebuilt",
|
|
51
|
+
entries: 1,
|
|
52
|
+
bots: 1,
|
|
53
|
+
indexState: "ready",
|
|
54
|
+
unknownOutcomes: 0,
|
|
55
|
+
hostJournalDiscrepancies: 2,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return Promise.resolve({
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
entries: [ENTRY],
|
|
61
|
+
page: path.includes("before=")
|
|
62
|
+
? { truncated: false }
|
|
63
|
+
: { truncated: true, nextCursor: "p1" },
|
|
64
|
+
total: 2,
|
|
65
|
+
indexState: "ready",
|
|
66
|
+
});
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
inject: () => {
|
|
70
|
+
throw new Error("unexpected client provider");
|
|
71
|
+
},
|
|
72
|
+
provide: (key, value) => {
|
|
73
|
+
if (key === auditStateKey) state = value;
|
|
74
|
+
return () => {};
|
|
75
|
+
},
|
|
76
|
+
slot: (registration) => {
|
|
77
|
+
slots.push(registration);
|
|
78
|
+
return () => slots.splice(slots.indexOf(registration), 1);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const disposers = auditClientPlugin(context);
|
|
82
|
+
if (!Array.isArray(disposers)) throw new Error("expected registrations");
|
|
83
|
+
return {
|
|
84
|
+
state: state as { value: AuditClientState },
|
|
85
|
+
slots,
|
|
86
|
+
calls,
|
|
87
|
+
dispose: () => {
|
|
88
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("Audit client contribution", () => {
|
|
94
|
+
test("mounts into the Bot settings outlet", () => {
|
|
95
|
+
const mounted = mount();
|
|
96
|
+
expect(mounted.slots.map((slot) => slot.slot)).toEqual([
|
|
97
|
+
"frockbot.bot-settings-sections",
|
|
98
|
+
]);
|
|
99
|
+
mounted.dispose();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("loads a Bot's activity and decodes it at the seam", async () => {
|
|
103
|
+
const mounted = mount();
|
|
104
|
+
await mounted.state.value.load("scout");
|
|
105
|
+
expect(mounted.state.value.entries).toHaveLength(1);
|
|
106
|
+
expect(mounted.state.value.total).toBe(2);
|
|
107
|
+
expect(mounted.state.value.botId).toBe("scout");
|
|
108
|
+
expect(mounted.calls[0]).toEqual([
|
|
109
|
+
"/api/audit?botId=scout",
|
|
110
|
+
undefined,
|
|
111
|
+
undefined,
|
|
112
|
+
]);
|
|
113
|
+
mounted.dispose();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("carries a filter chip into the query string", async () => {
|
|
117
|
+
const mounted = mount();
|
|
118
|
+
await mounted.state.value.load("scout", { kind: "mcp" });
|
|
119
|
+
expect(mounted.calls[0]?.[0]).toBe("/api/audit?botId=scout&kind=mcp");
|
|
120
|
+
mounted.dispose();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("pages with the cursor the previous answer gave it", async () => {
|
|
124
|
+
const mounted = mount();
|
|
125
|
+
await mounted.state.value.load("scout");
|
|
126
|
+
await mounted.state.value.loadMore("scout");
|
|
127
|
+
expect(mounted.calls[1]?.[0]).toBe("/api/audit?botId=scout&before=p1");
|
|
128
|
+
expect(mounted.state.value.entries).toHaveLength(2);
|
|
129
|
+
expect(mounted.state.value.nextCursor).toBeUndefined();
|
|
130
|
+
// Nothing further to ask for: a second call is a no-op, not a repeat.
|
|
131
|
+
await mounted.state.value.loadMore("scout");
|
|
132
|
+
expect(mounted.calls).toHaveLength(2);
|
|
133
|
+
mounted.dispose();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("holds the rebuild receipt, discrepancy count and all", async () => {
|
|
137
|
+
const mounted = mount();
|
|
138
|
+
await mounted.state.value.rebuild("scout");
|
|
139
|
+
expect(mounted.calls[0]).toEqual(["/api/audit/rebuild", "POST", "{}"]);
|
|
140
|
+
expect(mounted.state.value.receipt).toMatchObject({
|
|
141
|
+
status: "rebuilt",
|
|
142
|
+
hostJournalDiscrepancies: 2,
|
|
143
|
+
});
|
|
144
|
+
// And it reloads, so the rows on screen are the rebuilt ones.
|
|
145
|
+
expect(mounted.calls[1]?.[0]).toBe("/api/audit?botId=scout");
|
|
146
|
+
mounted.state.value.dismissReceipt();
|
|
147
|
+
expect(mounted.state.value.receipt).toBeUndefined();
|
|
148
|
+
mounted.dispose();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("names a failure rather than showing an empty panel", async () => {
|
|
152
|
+
const mounted = mount({
|
|
153
|
+
hostedRequest: () => Promise.reject(new Error("the gateway is away")),
|
|
154
|
+
});
|
|
155
|
+
await mounted.state.value.load("scout");
|
|
156
|
+
expect(mounted.state.value.error).toBe("the gateway is away");
|
|
157
|
+
expect(mounted.state.value.loaded).toBe(false);
|
|
158
|
+
mounted.dispose();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/// <reference path="../env.d.ts" />
|
|
2
|
+
|
|
3
|
+
// The Audit Package's hosted client Contribution.
|
|
4
|
+
//
|
|
5
|
+
// "The hosted client renders backend state and submits commands. It does not
|
|
6
|
+
// become an alternate authority." Every read here is decoded at the seam
|
|
7
|
+
// before a component sees it, and the one write is a rebuild — which changes
|
|
8
|
+
// no durable fact, only the projection of facts the Bots already hold.
|
|
9
|
+
import type { ClientPlugin } from "@frockbot/client-core";
|
|
10
|
+
import { ref } from "vue";
|
|
11
|
+
import {
|
|
12
|
+
decodeAuditRebuildReceiptV1,
|
|
13
|
+
decodeClientAuditPageV1,
|
|
14
|
+
} from "../shared.js";
|
|
15
|
+
import AuditSection from "./AuditSection.vue";
|
|
16
|
+
import { auditStateKey, type AuditClientState } from "./state.js";
|
|
17
|
+
|
|
18
|
+
function message(error: unknown, fallback: string): string {
|
|
19
|
+
return error instanceof Error ? error.message : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function queryString(
|
|
23
|
+
botId: string,
|
|
24
|
+
state: AuditClientState,
|
|
25
|
+
cursor?: string,
|
|
26
|
+
): string {
|
|
27
|
+
const params = new URLSearchParams({ botId });
|
|
28
|
+
if (state.filters.kind) params.set("kind", state.filters.kind);
|
|
29
|
+
if (state.filters.target) params.set("target", state.filters.target);
|
|
30
|
+
if (cursor) params.set("before", cursor);
|
|
31
|
+
return params.toString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const auditClientPlugin: ClientPlugin = (ctx) => {
|
|
35
|
+
const request = (
|
|
36
|
+
path: string,
|
|
37
|
+
method?: "GET" | "POST",
|
|
38
|
+
body?: string,
|
|
39
|
+
): Promise<unknown> => {
|
|
40
|
+
if (!ctx.transport.hostedRequest) {
|
|
41
|
+
throw new Error("Audit log is unavailable on this client");
|
|
42
|
+
}
|
|
43
|
+
return ctx.transport.hostedRequest(path, method, body);
|
|
44
|
+
};
|
|
45
|
+
const state = ref<AuditClientState>({
|
|
46
|
+
entries: [],
|
|
47
|
+
total: 0,
|
|
48
|
+
indexState: "ready",
|
|
49
|
+
filters: {},
|
|
50
|
+
loaded: false,
|
|
51
|
+
busy: false,
|
|
52
|
+
async load(botId, filters) {
|
|
53
|
+
state.value.busy = true;
|
|
54
|
+
if (filters) state.value.filters = filters;
|
|
55
|
+
try {
|
|
56
|
+
const page = decodeClientAuditPageV1(
|
|
57
|
+
await request(`/api/audit?${queryString(botId, state.value)}`),
|
|
58
|
+
);
|
|
59
|
+
state.value.botId = botId;
|
|
60
|
+
state.value.entries = page.entries;
|
|
61
|
+
state.value.total = page.total;
|
|
62
|
+
state.value.nextCursor = page.page.nextCursor;
|
|
63
|
+
state.value.indexState = page.indexState;
|
|
64
|
+
state.value.loaded = true;
|
|
65
|
+
state.value.error = undefined;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
state.value.error = message(error, "Could not load audit log");
|
|
68
|
+
} finally {
|
|
69
|
+
state.value.busy = false;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async loadMore(botId) {
|
|
73
|
+
const cursor = state.value.nextCursor;
|
|
74
|
+
if (!cursor) return;
|
|
75
|
+
state.value.busy = true;
|
|
76
|
+
try {
|
|
77
|
+
const page = decodeClientAuditPageV1(
|
|
78
|
+
await request(
|
|
79
|
+
`/api/audit?${queryString(botId, state.value, cursor)}`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
state.value.entries = [...state.value.entries, ...page.entries];
|
|
83
|
+
state.value.nextCursor = page.page.nextCursor;
|
|
84
|
+
state.value.indexState = page.indexState;
|
|
85
|
+
state.value.error = undefined;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
state.value.error = message(error, "Could not load more audit entries");
|
|
88
|
+
} finally {
|
|
89
|
+
state.value.busy = false;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async rebuild(botId) {
|
|
93
|
+
state.value.busy = true;
|
|
94
|
+
try {
|
|
95
|
+
// The receipt is the point of the button, not a side effect of it: it
|
|
96
|
+
// says how many entries the Bots' own runs actually account for, how
|
|
97
|
+
// many outcomes the durable log cannot explain, and how many effects
|
|
98
|
+
// the Computer host claims that no session event does.
|
|
99
|
+
state.value.receipt = decodeAuditRebuildReceiptV1(
|
|
100
|
+
await request("/api/audit/rebuild", "POST", JSON.stringify({})),
|
|
101
|
+
);
|
|
102
|
+
state.value.error = undefined;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
state.value.error = message(error, "Could not rebuild audit log");
|
|
105
|
+
} finally {
|
|
106
|
+
state.value.busy = false;
|
|
107
|
+
}
|
|
108
|
+
await state.value.load(botId);
|
|
109
|
+
},
|
|
110
|
+
dismissReceipt() {
|
|
111
|
+
state.value.receipt = undefined;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
ctx.provide(auditStateKey, state),
|
|
117
|
+
ctx.slot({
|
|
118
|
+
slot: "frockbot.bot-settings-sections",
|
|
119
|
+
order: 20,
|
|
120
|
+
component: AuditSection,
|
|
121
|
+
}),
|
|
122
|
+
];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export default auditClientPlugin;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { InjectionKey, Ref } from "vue";
|
|
2
|
+
import type {
|
|
3
|
+
AuditEntryV1,
|
|
4
|
+
AuditIndexStateV1,
|
|
5
|
+
AuditKindV1,
|
|
6
|
+
AuditRebuildReceiptV1,
|
|
7
|
+
} from "../shared.js";
|
|
8
|
+
|
|
9
|
+
/** The filters the Audit log can apply, as the chips set them. */
|
|
10
|
+
export interface AuditFiltersV1 {
|
|
11
|
+
kind?: AuditKindV1;
|
|
12
|
+
target?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuditClientState {
|
|
16
|
+
/** The Bot the loaded rows belong to; nothing is shown for another. */
|
|
17
|
+
botId?: string;
|
|
18
|
+
entries: AuditEntryV1[];
|
|
19
|
+
total: number;
|
|
20
|
+
nextCursor?: string;
|
|
21
|
+
indexState: AuditIndexStateV1;
|
|
22
|
+
filters: AuditFiltersV1;
|
|
23
|
+
loaded: boolean;
|
|
24
|
+
busy: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
/** The receipt of the most recent rebuild, held until the next load. */
|
|
27
|
+
receipt?: AuditRebuildReceiptV1;
|
|
28
|
+
load(botId: string, filters?: AuditFiltersV1): Promise<void>;
|
|
29
|
+
loadMore(botId: string): Promise<void>;
|
|
30
|
+
rebuild(botId: string): Promise<void>;
|
|
31
|
+
dismissReceipt(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const auditStateKey: InjectionKey<Ref<AuditClientState>> =
|
|
35
|
+
Symbol("audit-state");
|