@frockbot/plugin-audit 0.3.4 → 0.3.5
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/package.json +6 -6
- package/src/bot.test.ts +52 -0
- package/src/bot.ts +28 -4
- package/src/classify.test.ts +38 -2
- package/src/classify.ts +55 -2
- package/src/redact.test.ts +55 -0
- package/src/redact.ts +5 -1
- package/src/store.test.ts +99 -0
- package/src/store.ts +73 -17
- package/src/testing.ts +65 -18
- package/src/user.test.ts +19 -3
- package/src/user.ts +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-audit",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -25,11 +25,11 @@
|
|
|
25
25
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@frockbot/client-core": "0.3.
|
|
29
|
-
"@frockbot/client-ui": "0.3.
|
|
30
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
31
|
-
"@frockbot/plugin-shell": "0.3.
|
|
32
|
-
"@frockbot/secret-shapes": "0.3.
|
|
28
|
+
"@frockbot/client-core": "0.3.5",
|
|
29
|
+
"@frockbot/client-ui": "0.3.5",
|
|
30
|
+
"@frockbot/kernel-contracts": "0.3.5",
|
|
31
|
+
"@frockbot/plugin-shell": "0.3.5",
|
|
32
|
+
"@frockbot/secret-shapes": "0.3.5",
|
|
33
33
|
"cordis": "4.0.0-rc.8",
|
|
34
34
|
"vue": "3.5.41"
|
|
35
35
|
},
|
package/src/bot.test.ts
CHANGED
|
@@ -248,3 +248,55 @@ describe("the Bot Durable Object's outbox", () => {
|
|
|
248
248
|
expect(await outbox.state()).toEqual({ pending: 0, truncated: false });
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
|
+
|
|
252
|
+
describe("what the row is allowed to claim", () => {
|
|
253
|
+
test("an approval-gated command is not `ok` before anybody approved it", async () => {
|
|
254
|
+
const entries = await auditEntriesFromStoredRunV1(
|
|
255
|
+
"foreman",
|
|
256
|
+
run([
|
|
257
|
+
call("tool:1:1:0", "machine_exec", {
|
|
258
|
+
command: "rm -rf /tmp/build",
|
|
259
|
+
machineId: "994dc2ee-1",
|
|
260
|
+
}),
|
|
261
|
+
result("tool:1:1:0", {
|
|
262
|
+
content:
|
|
263
|
+
'Command "cmd-1" is waiting on the user\'s approval. Nothing has run.',
|
|
264
|
+
}),
|
|
265
|
+
]),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// The approval ends the Turn before anything runs, and `isError` is false
|
|
269
|
+
// at queue time — so the row used to say `ok` for a command that had not
|
|
270
|
+
// run and might never run.
|
|
271
|
+
expect(entries[0]).toMatchObject({
|
|
272
|
+
toolName: "machine_exec",
|
|
273
|
+
target: "machine:994dc2ee-1",
|
|
274
|
+
outcome: "unknown",
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a namespaced dynamic tool is recorded under the tool that ran", async () => {
|
|
279
|
+
const entries = await auditEntriesFromStoredRunV1(
|
|
280
|
+
"foreman",
|
|
281
|
+
run([
|
|
282
|
+
call("tool:1:1:0", "call_dynamic_tool", {
|
|
283
|
+
namespace: "frockbot",
|
|
284
|
+
toolName: "package_author",
|
|
285
|
+
input: { packageId: "acme", path: "src/index.ts" },
|
|
286
|
+
}),
|
|
287
|
+
result("tool:1:1:0", { content: "written" }),
|
|
288
|
+
]),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// `package_author` produced no row at all before: it is a namespaced
|
|
292
|
+
// dynamic tool, so the journalled name is `call_dynamic_tool`, and the
|
|
293
|
+
// same hole hid every Composio and publisher call.
|
|
294
|
+
expect(entries).toHaveLength(1);
|
|
295
|
+
expect(entries[0]).toMatchObject({
|
|
296
|
+
toolName: "package_author",
|
|
297
|
+
kind: "file",
|
|
298
|
+
outcome: "ok",
|
|
299
|
+
});
|
|
300
|
+
expect(entries[0]?.preview).toContain("acme");
|
|
301
|
+
});
|
|
302
|
+
});
|
package/src/bot.ts
CHANGED
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// It reads the *stored* run rather than the client projection, because the
|
|
22
22
|
// client projection drops `call.input` and the argument digest needs the exact
|
|
23
23
|
// arguments.
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
auditKindForToolV1,
|
|
26
|
+
dynamicToolInputV1,
|
|
27
|
+
resolveDynamicToolNameV1,
|
|
28
|
+
} from "./classify.js";
|
|
25
29
|
import { auditArgumentDigestV1, auditPreviewV1 } from "./redact.js";
|
|
26
30
|
import {
|
|
27
31
|
AUDIT_MAX_OUTBOX_V1,
|
|
@@ -71,6 +75,17 @@ export function isSettledAuditRunV1(run: { status: string }): boolean {
|
|
|
71
75
|
);
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/**
|
|
79
|
+
* A tool result that says the effect has not run yet.
|
|
80
|
+
*
|
|
81
|
+
* An approval-gated `machine_exec` answers the model at *queue* time, before
|
|
82
|
+
* anybody has approved anything, and `isError` is false — so the row said `ok`
|
|
83
|
+
* for a command that had not run and might never run. The durable log does not
|
|
84
|
+
* yet know how it ended, which is exactly what `unknown` means.
|
|
85
|
+
*/
|
|
86
|
+
const AWAITING_APPROVAL =
|
|
87
|
+
/\bnothing has run\b|waiting on the user's approval|awaiting (?:your )?approval|queued for approval/i;
|
|
88
|
+
|
|
74
89
|
function outcomeFor(
|
|
75
90
|
result: { isError?: boolean; status?: string; content?: string } | undefined,
|
|
76
91
|
): AuditOutcomeV1 {
|
|
@@ -79,7 +94,9 @@ function outcomeFor(
|
|
|
79
94
|
// silent classification the constitution's reconciliation rule forbids.
|
|
80
95
|
if (!result) return "unknown";
|
|
81
96
|
if (result.status === "interrupted") return "interrupted";
|
|
82
|
-
if (result.isError !== true)
|
|
97
|
+
if (result.isError !== true) {
|
|
98
|
+
return AWAITING_APPROVAL.test(result.content ?? "") ? "unknown" : "ok";
|
|
99
|
+
}
|
|
83
100
|
// A tool that declined before doing anything is a refusal, which is a
|
|
84
101
|
// materially different fact from an effect that ran and failed.
|
|
85
102
|
return /\brefus|not allowed|denied|blocked while\b/i.test(
|
|
@@ -122,6 +139,11 @@ export async function auditEntriesFromStoredRunV1(
|
|
|
122
139
|
if (!occurrenceId || !name) continue;
|
|
123
140
|
const classification = auditKindForToolV1(name, event.input);
|
|
124
141
|
if (!classification) continue;
|
|
142
|
+
// The row names the tool that ran, not the wrapper it was journalled
|
|
143
|
+
// under, and previews the arguments that tool was actually given.
|
|
144
|
+
const toolName = resolveDynamicToolNameV1(name, event.input);
|
|
145
|
+
const toolInput =
|
|
146
|
+
toolName === name ? event.input : dynamicToolInputV1(event.input);
|
|
125
147
|
let coordinates: { turn: number; step: number; ordinal: number };
|
|
126
148
|
try {
|
|
127
149
|
coordinates = decodeAuditOccurrenceIdV1(occurrenceId);
|
|
@@ -146,9 +168,11 @@ export async function auditEntriesFromStoredRunV1(
|
|
|
146
168
|
at,
|
|
147
169
|
kind: classification.kind,
|
|
148
170
|
target: classification.target,
|
|
149
|
-
toolName
|
|
171
|
+
toolName,
|
|
172
|
+
// The digest stays over the exact argument JSON the durable `tool/call`
|
|
173
|
+
// event holds, so a row written months ago still reproduces.
|
|
150
174
|
argumentDigest: await auditArgumentDigestV1(event.input),
|
|
151
|
-
preview: auditPreviewV1(classification.kind,
|
|
175
|
+
preview: auditPreviewV1(classification.kind, toolName, toolInput),
|
|
152
176
|
outcome: outcomeFor(result),
|
|
153
177
|
...(result?.content === undefined
|
|
154
178
|
? {}
|
package/src/classify.test.ts
CHANGED
|
@@ -104,20 +104,56 @@ describe("the classifier table", () => {
|
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
test("a
|
|
107
|
+
test("a Computer tool is audited against the Computer, whatever it claims", () => {
|
|
108
|
+
// `machineId` is model-supplied. Deriving the target from it on every tool
|
|
109
|
+
// let a Bot run a command on the Computer and have the durable audit row
|
|
110
|
+
// say it ran on the User's laptop — the one field the row exists to be
|
|
111
|
+
// trusted on.
|
|
108
112
|
expect(
|
|
109
113
|
auditKindForToolV1("computer_exec", {
|
|
110
114
|
command: "ls",
|
|
111
115
|
machineId: "994dc2ee-1",
|
|
112
116
|
}),
|
|
117
|
+
).toEqual({ kind: "shell", target: "computer" });
|
|
118
|
+
// The machine's own verbs still name the machine they reached.
|
|
119
|
+
expect(
|
|
120
|
+
auditKindForToolV1("machine_exec", {
|
|
121
|
+
command: "ls",
|
|
122
|
+
machineId: "994dc2ee-1",
|
|
123
|
+
}),
|
|
113
124
|
).toEqual({ kind: "shell", target: "machine:994dc2ee-1" });
|
|
114
125
|
// A malformed machine id is the Bot's own Computer, not a target the row
|
|
115
126
|
// would be lying about.
|
|
116
127
|
expect(
|
|
117
|
-
auditKindForToolV1("
|
|
128
|
+
auditKindForToolV1("machine_exec", { command: "ls", machineId: "../x" }),
|
|
118
129
|
).toEqual({ kind: "shell", target: "computer" });
|
|
119
130
|
});
|
|
120
131
|
|
|
132
|
+
test("a namespaced dynamic tool is classified on the tool it resolves to", () => {
|
|
133
|
+
// `package_author` was dead: it is a namespaced dynamic tool, so the
|
|
134
|
+
// journalled name is `call_dynamic_tool` and no row was ever produced —
|
|
135
|
+
// the same hole hid every Composio and publisher call.
|
|
136
|
+
expect(
|
|
137
|
+
auditKindForToolV1("call_dynamic_tool", {
|
|
138
|
+
namespace: "frockbot",
|
|
139
|
+
toolName: "package_author",
|
|
140
|
+
input: { packageId: "acme" },
|
|
141
|
+
}),
|
|
142
|
+
).toEqual({ kind: "file", target: "computer" });
|
|
143
|
+
// A wrapper that names nothing resolvable stays the wrapper, and the
|
|
144
|
+
// wrapper is not an audited effect.
|
|
145
|
+
expect(auditKindForToolV1("call_dynamic_tool", {})).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("an MCP tool whose own name contains __ still names its server", () => {
|
|
149
|
+
// The slug capture was greedy: `mcp__gh__list__files` reported server
|
|
150
|
+
// `gh__list`, a target no Connection can resolve to a host.
|
|
151
|
+
expect(auditKindForToolV1("mcp__gh__list__files", {})).toEqual({
|
|
152
|
+
kind: "mcp",
|
|
153
|
+
target: "remote:gh",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
121
157
|
test("is pure: the same call always classifies the same way", () => {
|
|
122
158
|
const first = auditKindForToolV1("computer_exec", { command: "ls" });
|
|
123
159
|
const second = auditKindForToolV1("computer_exec", { command: "ls" });
|
package/src/classify.ts
CHANGED
|
@@ -77,13 +77,56 @@ const MACHINE_SHELL_TOOL = "machine_exec";
|
|
|
77
77
|
*/
|
|
78
78
|
const MACHINE_MESSAGES_PREFIX = "machine_messages_";
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// The slug capture is LAZY. Greedy, `mcp__gh__list__files` reported server
|
|
81
|
+
// `gh__list` — a target that names no Connection, so `resolveAuditTargetV1`
|
|
82
|
+
// could never resolve it to a host and the row filtered under a server nobody
|
|
83
|
+
// has. The slug is the first segment; everything after the second `__` is the
|
|
84
|
+
// remote tool's own name, `__` included.
|
|
85
|
+
const MCP_TOOL = /^mcp__([a-zA-Z0-9_]{1,64}?)__(.{1,96})$/;
|
|
81
86
|
const MACHINE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
82
87
|
|
|
83
88
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
84
89
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/** The wrapper every namespaced Package tool is journalled under. */
|
|
93
|
+
const DYNAMIC_TOOL = "call_dynamic_tool";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The tool a call actually made.
|
|
97
|
+
*
|
|
98
|
+
* `package_author` was dead code here: it is a namespaced dynamic tool, so the
|
|
99
|
+
* journalled `tool/call` name is `call_dynamic_tool` and no row was ever
|
|
100
|
+
* produced for it — the same hole hid every Composio and publisher call. The
|
|
101
|
+
* wrapper's own input names the tool, exactly as the provider's presented name
|
|
102
|
+
* does, so the classifier reads it off the call and stays pure.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveDynamicToolNameV1(name: string, input: unknown): string {
|
|
105
|
+
if (name !== DYNAMIC_TOOL || !isObject(input)) return name;
|
|
106
|
+
const { namespace, toolName } = input;
|
|
107
|
+
if (typeof namespace !== "string" || typeof toolName !== "string") {
|
|
108
|
+
return name;
|
|
109
|
+
}
|
|
110
|
+
return namespace === "frockbot" ? toolName : `${namespace}/${toolName}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The arguments the wrapped tool was actually given. */
|
|
114
|
+
export function dynamicToolInputV1(input: unknown): unknown {
|
|
115
|
+
if (!isObject(input)) return input;
|
|
116
|
+
return Object.hasOwn(input, "input") ? input.input : input;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a tool reaches the User's registered machine rather than the Computer. */
|
|
120
|
+
function isMachineToolV1(name: string): boolean {
|
|
121
|
+
return (
|
|
122
|
+
name === MACHINE_SHELL_TOOL ||
|
|
123
|
+
name.startsWith(MACHINE_MESSAGES_PREFIX) ||
|
|
124
|
+
name === "machine_read" ||
|
|
125
|
+
name === "machine_copy_to_computer" ||
|
|
126
|
+
name === "machine_copy_from_computer"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
87
130
|
/**
|
|
88
131
|
* The registered Mac, when the call named one.
|
|
89
132
|
*
|
|
@@ -114,7 +157,17 @@ export function auditKindForToolV1(
|
|
|
114
157
|
name: string,
|
|
115
158
|
input: unknown,
|
|
116
159
|
): AuditClassificationV1 | undefined {
|
|
117
|
-
|
|
160
|
+
// Only a tool that actually reaches a registered machine may be targeted at
|
|
161
|
+
// one. The target used to come off `machineId` for every tool, and
|
|
162
|
+
// `machineId` is model-supplied: a Bot could run a command on the Computer
|
|
163
|
+
// and have the audit row say it ran on the User's laptop. A Computer tool is
|
|
164
|
+
// audited against the Computer, whatever its arguments claim.
|
|
165
|
+
const resolved = resolveDynamicToolNameV1(name, input);
|
|
166
|
+
const onMachine = isMachineToolV1(resolved)
|
|
167
|
+
? (machineTarget(input) ?? AUDIT_TARGET_COMPUTER_V1)
|
|
168
|
+
: AUDIT_TARGET_COMPUTER_V1;
|
|
169
|
+
const onComputer = onMachine;
|
|
170
|
+
name = resolved;
|
|
118
171
|
if (name === "computer_exec") {
|
|
119
172
|
// A background command outlives the Turn that launched it and is acted on
|
|
120
173
|
// afterwards by the three `computer_process_*` tools, so it is a process
|
package/src/redact.test.ts
CHANGED
|
@@ -89,3 +89,58 @@ describe("the argument digest", () => {
|
|
|
89
89
|
);
|
|
90
90
|
});
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("secrets a preview must not carry", () => {
|
|
94
|
+
test("redacts an env-var assignment whose keyword sits behind an underscore", () => {
|
|
95
|
+
// `_` is a word character, so the old `\bsecret\b` anchor did not match
|
|
96
|
+
// inside `AWS_SECRET_ACCESS_KEY` and these landed verbatim in a durable
|
|
97
|
+
// table a person reads — the exact shape a leased credential takes in a
|
|
98
|
+
// `computer_exec` command.
|
|
99
|
+
for (const command of [
|
|
100
|
+
"AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIK7MDENGbPxRfiCYEXAMPLEKEY aws s3 ls",
|
|
101
|
+
"GITHUB_TOKEN=abcdefghijklmnopqrst gh pr list",
|
|
102
|
+
"MYSQL_PASSWORD=hunter2hunter2 mysql -u root",
|
|
103
|
+
]) {
|
|
104
|
+
const preview = auditPreviewV1("shell", "computer_exec", { command });
|
|
105
|
+
expect(preview).toContain("[redacted:credential-assignment]");
|
|
106
|
+
expect(preview).not.toContain("wJalrXUtnFEMIK");
|
|
107
|
+
expect(preview).not.toContain("abcdefghijklmnopqrst");
|
|
108
|
+
expect(preview).not.toContain("hunter2hunter2");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("redacts credentials carried in a URL", () => {
|
|
113
|
+
const withUser = auditPreviewV1("browser", "computer_browser", {
|
|
114
|
+
action: "open",
|
|
115
|
+
url: "https://alice:s3cr3tpass@internal.example.com/reports",
|
|
116
|
+
});
|
|
117
|
+
expect(withUser).toContain("[redacted:url-credentials]");
|
|
118
|
+
expect(withUser).not.toContain("s3cr3tpass");
|
|
119
|
+
|
|
120
|
+
for (const [url, secret] of [
|
|
121
|
+
["https://example.com/cb?token=abcdefghijklmnop", "abcdefghijklmnop"],
|
|
122
|
+
["https://example.com/cb?code=4%2F0AeanS0abcdefgh", "AeanS0abcdefgh"],
|
|
123
|
+
["https://example.com/f.zip?sig=aGVsbG93b3JsZA", "aGVsbG93b3JsZA"],
|
|
124
|
+
] as const) {
|
|
125
|
+
const preview = auditPreviewV1("browser", "computer_browser", {
|
|
126
|
+
action: "open",
|
|
127
|
+
url,
|
|
128
|
+
});
|
|
129
|
+
// Which shape catches it does not matter; that it never reaches the
|
|
130
|
+
// durable table does.
|
|
131
|
+
expect(preview).toMatch(/\[redacted:/);
|
|
132
|
+
expect(preview).not.toContain(secret);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("never previews the body of a memory or skill write", () => {
|
|
137
|
+
const preview = auditPreviewV1("file", "memory_write", {
|
|
138
|
+
path: "by-agent/scout/profile.md",
|
|
139
|
+
text: "Tim's home address is 12 Somewhere Street and his PIN is 4021.",
|
|
140
|
+
});
|
|
141
|
+
// The audit row says where something was written. What was written is the
|
|
142
|
+
// Workspace's business and the digest's.
|
|
143
|
+
expect(preview).toBe("by-agent/scout/profile.md");
|
|
144
|
+
expect(preview).not.toContain("Somewhere Street");
|
|
145
|
+
});
|
|
146
|
+
});
|
package/src/redact.ts
CHANGED
|
@@ -43,7 +43,11 @@ const PREVIEW_FIELDS: Record<AuditKindV1, readonly string[]> = {
|
|
|
43
43
|
shell: ["command", "machineId"],
|
|
44
44
|
browser: ["action", "url", "role", "name", "label", "key"],
|
|
45
45
|
process: ["action", "command", "processId", "machineId"],
|
|
46
|
-
|
|
46
|
+
// No `text`: it previewed up to 200 characters of a `memory_write` or
|
|
47
|
+
// `skill_write` body into a durable table a person reads later. What was
|
|
48
|
+
// written is the Workspace's business and the digest's; the audit row says
|
|
49
|
+
// where it was written, which is the question an audit answers.
|
|
50
|
+
file: ["path", "root", "project", "packageId", "skill"],
|
|
47
51
|
mcp: [],
|
|
48
52
|
};
|
|
49
53
|
|
package/src/store.test.ts
CHANGED
|
@@ -166,3 +166,102 @@ describe("the audit table", () => {
|
|
|
166
166
|
expect(table.count()).toBe(0);
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
|
+
|
|
170
|
+
describe("a rebuild that fails", () => {
|
|
171
|
+
test("leaves the live table exactly as it was", async () => {
|
|
172
|
+
const table = store();
|
|
173
|
+
table.insert([entry(), entry({ occurrenceId: "tool:1:1:1" })]);
|
|
174
|
+
expect(table.count()).toBe(2);
|
|
175
|
+
|
|
176
|
+
// The rebuild used to `DELETE FROM` the live table before it fetched page
|
|
177
|
+
// one, so any source failure left a truncated table still reporting
|
|
178
|
+
// `ready` — a person would read an audit log with rows silently missing
|
|
179
|
+
// and nothing saying so.
|
|
180
|
+
await expect(
|
|
181
|
+
table.rebuild([
|
|
182
|
+
{
|
|
183
|
+
botId: "foreman",
|
|
184
|
+
page: async () => {
|
|
185
|
+
throw new Error("the Bot object is unreachable");
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
]),
|
|
189
|
+
).rejects.toThrow("unreachable");
|
|
190
|
+
|
|
191
|
+
expect(table.count()).toBe(2);
|
|
192
|
+
expect(table.state()).toBe("ready");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("a rebuild that succeeds replaces the table wholesale", async () => {
|
|
196
|
+
const table = store();
|
|
197
|
+
table.insert([entry({ occurrenceId: "tool:9:9:9" })]);
|
|
198
|
+
|
|
199
|
+
const receipt = await table.rebuild([
|
|
200
|
+
{
|
|
201
|
+
botId: "foreman",
|
|
202
|
+
page: async (cursor?: string) =>
|
|
203
|
+
cursor === undefined
|
|
204
|
+
? {
|
|
205
|
+
entries: [entry({ occurrenceId: "tool:1:1:0" })],
|
|
206
|
+
nextCursor: "p1",
|
|
207
|
+
}
|
|
208
|
+
: { entries: [entry({ occurrenceId: "tool:1:1:1" })] },
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
expect(receipt).toMatchObject({ entries: 2, bots: 1, indexState: "ready" });
|
|
213
|
+
expect(table.count()).toBe(2);
|
|
214
|
+
// The row the old table held and the sources no longer offer is gone: a
|
|
215
|
+
// rebuild is a replacement, not a merge.
|
|
216
|
+
expect(
|
|
217
|
+
table
|
|
218
|
+
.query({})
|
|
219
|
+
.entries.map((row) => row.occurrenceId)
|
|
220
|
+
.sort(),
|
|
221
|
+
).toEqual(["tool:1:1:0", "tool:1:1:1"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("refuses a second rebuild while one is running", async () => {
|
|
225
|
+
const table = store();
|
|
226
|
+
let release: (() => void) | undefined;
|
|
227
|
+
const gate = new Promise<void>((resolve) => {
|
|
228
|
+
release = resolve;
|
|
229
|
+
});
|
|
230
|
+
const first = table.rebuild([
|
|
231
|
+
{
|
|
232
|
+
botId: "foreman",
|
|
233
|
+
page: async () => {
|
|
234
|
+
await gate;
|
|
235
|
+
return { entries: [entry()] };
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
]);
|
|
239
|
+
|
|
240
|
+
// `audit-rebuilding` was written and never read as a lock, so two
|
|
241
|
+
// concurrent rebuilds wiped each other.
|
|
242
|
+
await expect(
|
|
243
|
+
table.rebuild([
|
|
244
|
+
{ botId: "foreman", page: async () => ({ entries: [] }) },
|
|
245
|
+
]),
|
|
246
|
+
).rejects.toThrow("already running");
|
|
247
|
+
|
|
248
|
+
release?.();
|
|
249
|
+
await first;
|
|
250
|
+
expect(table.state()).toBe("ready");
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe("retention", () => {
|
|
255
|
+
test("is enforced on a read too, not only when something is written", () => {
|
|
256
|
+
let now = Date.parse("2026-08-31T00:00:00.000Z");
|
|
257
|
+
const table = store({ maxAgeMs: 60_000, now: () => now });
|
|
258
|
+
table.insert([entry({ at: "2026-08-31T00:00:00.000Z" })]);
|
|
259
|
+
expect(table.count()).toBe(1);
|
|
260
|
+
|
|
261
|
+
// A Bot nobody has spoken to since kept every row past the age bound,
|
|
262
|
+
// because eviction only ever ran on insert. Retention is a promise about
|
|
263
|
+
// time.
|
|
264
|
+
now += 10 * 60_000;
|
|
265
|
+
expect(table.query({}).entries).toHaveLength(0);
|
|
266
|
+
});
|
|
267
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -42,6 +42,15 @@ export interface AuditSqlV1 {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
const TABLE = "audit_entries";
|
|
45
|
+
/**
|
|
46
|
+
* Where a rebuild accumulates rows before it replaces the live table.
|
|
47
|
+
*
|
|
48
|
+
* A rebuild used to `DELETE FROM` the live table before it fetched page one,
|
|
49
|
+
* so any source failure left a truncated table still reporting `ready`. It
|
|
50
|
+
* fills this instead and swaps at the end, which makes a failed rebuild cost
|
|
51
|
+
* nothing.
|
|
52
|
+
*/
|
|
53
|
+
const SHADOW_TABLE = "audit_entries_rebuild";
|
|
45
54
|
const META_TABLE = "audit_meta";
|
|
46
55
|
const TRUNCATED_KEY = "audit-truncated";
|
|
47
56
|
const REBUILDING_KEY = "audit-rebuilding";
|
|
@@ -49,6 +58,26 @@ const REBUILDING_KEY = "audit-rebuilding";
|
|
|
49
58
|
/** The page size a rebuild pulls from one Bot at a time. */
|
|
50
59
|
export const AUDIT_REBUILD_PAGE_V1 = 32;
|
|
51
60
|
|
|
61
|
+
/**
|
|
62
|
+
* How long a rebuild holds the lock before another may take it.
|
|
63
|
+
*
|
|
64
|
+
* `REBUILDING_KEY` was written and never read as a lock, so two concurrent
|
|
65
|
+
* rebuilds wiped each other. It now records when the rebuild started, and a
|
|
66
|
+
* marker older than this is a rebuild whose isolate died — not a reason for
|
|
67
|
+
* the table to be unrebuildable for ever.
|
|
68
|
+
*/
|
|
69
|
+
export const AUDIT_REBUILD_LOCK_MS = 10 * 60_000;
|
|
70
|
+
|
|
71
|
+
/** The columns both the live table and the rebuild's shadow carry. */
|
|
72
|
+
const AUDIT_COLUMNS =
|
|
73
|
+
"bot_id TEXT NOT NULL, run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, " +
|
|
74
|
+
"turn INTEGER NOT NULL, step INTEGER NOT NULL, ordinal INTEGER NOT NULL, " +
|
|
75
|
+
"effect_id TEXT NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, " +
|
|
76
|
+
"target TEXT NOT NULL, tool_name TEXT NOT NULL, argument_digest TEXT NOT NULL, " +
|
|
77
|
+
"preview TEXT NOT NULL, outcome TEXT NOT NULL, exit_code INTEGER, " +
|
|
78
|
+
"duration_ms INTEGER, bytes_out INTEGER, " +
|
|
79
|
+
"PRIMARY KEY (bot_id, run_id, occurrence_id)";
|
|
80
|
+
|
|
52
81
|
export interface AuditStoreOptionsV1 {
|
|
53
82
|
sql: AuditSqlV1;
|
|
54
83
|
/** Overridable so a test can drive eviction without twenty thousand rows. */
|
|
@@ -134,16 +163,7 @@ export class AuditStoreV1 {
|
|
|
134
163
|
/** Creates the table if it is absent. Safe to call on every request. */
|
|
135
164
|
open(): void {
|
|
136
165
|
if (this.opened) return;
|
|
137
|
-
this.sql.exec(
|
|
138
|
-
`CREATE TABLE IF NOT EXISTS ${TABLE} (` +
|
|
139
|
-
"bot_id TEXT NOT NULL, run_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, " +
|
|
140
|
-
"turn INTEGER NOT NULL, step INTEGER NOT NULL, ordinal INTEGER NOT NULL, " +
|
|
141
|
-
"effect_id TEXT NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, " +
|
|
142
|
-
"target TEXT NOT NULL, tool_name TEXT NOT NULL, argument_digest TEXT NOT NULL, " +
|
|
143
|
-
"preview TEXT NOT NULL, outcome TEXT NOT NULL, exit_code INTEGER, " +
|
|
144
|
-
"duration_ms INTEGER, bytes_out INTEGER, " +
|
|
145
|
-
"PRIMARY KEY (bot_id, run_id, occurrence_id))",
|
|
146
|
-
);
|
|
166
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS ${TABLE} (${AUDIT_COLUMNS})`);
|
|
147
167
|
this.sql.exec(`CREATE INDEX IF NOT EXISTS ${TABLE}_at ON ${TABLE} (at)`);
|
|
148
168
|
this.sql.exec(
|
|
149
169
|
`CREATE INDEX IF NOT EXISTS ${TABLE}_bot_at ON ${TABLE} (bot_id, at)`,
|
|
@@ -204,11 +224,18 @@ export class AuditStoreV1 {
|
|
|
204
224
|
*/
|
|
205
225
|
insert(entries: readonly AuditEntryV1[]): number {
|
|
206
226
|
this.open();
|
|
227
|
+
const inserted = this.insertInto(TABLE, entries);
|
|
228
|
+
this.evict();
|
|
229
|
+
return inserted;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The insert half, named so a rebuild can aim it at its shadow table. */
|
|
233
|
+
private insertInto(table: string, entries: readonly AuditEntryV1[]): number {
|
|
207
234
|
let inserted = 0;
|
|
208
235
|
for (const entry of entries) {
|
|
209
236
|
const existing = this.sql
|
|
210
237
|
.exec<{ n: number }>(
|
|
211
|
-
`SELECT count(*) AS n FROM ${
|
|
238
|
+
`SELECT count(*) AS n FROM ${table} WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?`,
|
|
212
239
|
entry.botId,
|
|
213
240
|
entry.runId,
|
|
214
241
|
entry.occurrenceId,
|
|
@@ -216,7 +243,7 @@ export class AuditStoreV1 {
|
|
|
216
243
|
.toArray();
|
|
217
244
|
if (Number(existing[0]?.n ?? 0) > 0) continue;
|
|
218
245
|
this.sql.exec(
|
|
219
|
-
`INSERT INTO ${
|
|
246
|
+
`INSERT INTO ${table} (bot_id, run_id, occurrence_id, turn, step, ordinal, ` +
|
|
220
247
|
"effect_id, at, kind, target, tool_name, argument_digest, preview, outcome, " +
|
|
221
248
|
"exit_code, duration_ms, bytes_out) " +
|
|
222
249
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
@@ -240,7 +267,6 @@ export class AuditStoreV1 {
|
|
|
240
267
|
);
|
|
241
268
|
inserted += 1;
|
|
242
269
|
}
|
|
243
|
-
this.evict();
|
|
244
270
|
return inserted;
|
|
245
271
|
}
|
|
246
272
|
|
|
@@ -333,6 +359,10 @@ export class AuditStoreV1 {
|
|
|
333
359
|
limit?: number;
|
|
334
360
|
}): { entries: AuditEntryV1[]; nextCursor?: string; total: number } {
|
|
335
361
|
this.open();
|
|
362
|
+
// Retention is a promise about time, so it cannot be enforced only when
|
|
363
|
+
// something is written: a Bot nobody has spoken to for a year kept every
|
|
364
|
+
// row past the 180-day bound simply because no insert came to evict them.
|
|
365
|
+
this.evict();
|
|
336
366
|
const limit = Math.min(Math.max(request.limit ?? 50, 1), 500);
|
|
337
367
|
const offset = decodeAuditOffsetV1(request.before);
|
|
338
368
|
const clauses: string[] = [];
|
|
@@ -391,17 +421,33 @@ export class AuditStoreV1 {
|
|
|
391
421
|
sources: readonly AuditEntrySourceV1[],
|
|
392
422
|
): Promise<AuditRebuildOutcomeV1> {
|
|
393
423
|
this.open();
|
|
394
|
-
this.
|
|
395
|
-
|
|
396
|
-
|
|
424
|
+
const held = this.meta(REBUILDING_KEY);
|
|
425
|
+
const startedAt = held === undefined ? undefined : Number(held);
|
|
426
|
+
if (
|
|
427
|
+
startedAt !== undefined &&
|
|
428
|
+
Number.isFinite(startedAt) &&
|
|
429
|
+
this.now() - startedAt < AUDIT_REBUILD_LOCK_MS
|
|
430
|
+
) {
|
|
431
|
+
// The marker was written and never read as a lock, so two concurrent
|
|
432
|
+
// rebuilds wiped each other. It is a lock now.
|
|
433
|
+
throw new Error("an audit rebuild is already running");
|
|
434
|
+
}
|
|
435
|
+
this.setMeta(REBUILDING_KEY, String(this.now()));
|
|
397
436
|
let entries = 0;
|
|
398
437
|
try {
|
|
438
|
+
// Append, then swap. Nothing touches the live table until every source
|
|
439
|
+
// has answered, so a source that fails halfway costs a rebuild and not
|
|
440
|
+
// the table — where before the rows were deleted before page one was
|
|
441
|
+
// even fetched, and a failure left a truncated table reporting `ready`.
|
|
442
|
+
this.sql.exec(`DROP TABLE IF EXISTS ${SHADOW_TABLE}`);
|
|
443
|
+
this.sql.exec(`CREATE TABLE ${SHADOW_TABLE} (${AUDIT_COLUMNS})`);
|
|
399
444
|
for (const source of sources) {
|
|
400
445
|
let cursor: string | undefined;
|
|
401
446
|
let pages = 0;
|
|
402
447
|
do {
|
|
403
448
|
const page = await source.page(cursor);
|
|
404
|
-
entries += this.
|
|
449
|
+
entries += this.insertInto(
|
|
450
|
+
SHADOW_TABLE,
|
|
405
451
|
page.entries.filter((entry) => entry.botId === source.botId),
|
|
406
452
|
);
|
|
407
453
|
cursor = page.nextCursor;
|
|
@@ -410,6 +456,16 @@ export class AuditStoreV1 {
|
|
|
410
456
|
// that never stops offering pages is a fault, not a large Bot.
|
|
411
457
|
} while (cursor && pages < 10_000);
|
|
412
458
|
}
|
|
459
|
+
this.sql.exec(`DROP TABLE ${TABLE}`);
|
|
460
|
+
this.sql.exec(`ALTER TABLE ${SHADOW_TABLE} RENAME TO ${TABLE}`);
|
|
461
|
+
// The indexes went with the table the rename replaced.
|
|
462
|
+
this.opened = false;
|
|
463
|
+
this.open();
|
|
464
|
+
this.setMeta(TRUNCATED_KEY, undefined);
|
|
465
|
+
this.evict();
|
|
466
|
+
} catch (error) {
|
|
467
|
+
this.sql.exec(`DROP TABLE IF EXISTS ${SHADOW_TABLE}`);
|
|
468
|
+
throw error;
|
|
413
469
|
} finally {
|
|
414
470
|
this.setMeta(REBUILDING_KEY, undefined);
|
|
415
471
|
}
|
package/src/testing.ts
CHANGED
|
@@ -65,11 +65,33 @@ function order(left: FakeRow, right: FakeRow): number {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
export class FakeAuditSql implements AuditSqlV1 {
|
|
68
|
-
|
|
68
|
+
/**
|
|
69
|
+
* One row list per table, because a rebuild fills a shadow table and swaps
|
|
70
|
+
* it in: a fake with a single list could not tell the two apart and would
|
|
71
|
+
* report the swap as working whatever the store did.
|
|
72
|
+
*/
|
|
73
|
+
private tables = new Map<string, FakeRow[]>([["audit_entries", []]]);
|
|
69
74
|
private meta = new Map<string, string>();
|
|
70
75
|
/** Every statement the module issued, for tests that assert on shape. */
|
|
71
76
|
readonly statements: string[] = [];
|
|
72
77
|
|
|
78
|
+
/** The table one statement names, defaulting to the live one. */
|
|
79
|
+
private static table(sql: string): string {
|
|
80
|
+
return (
|
|
81
|
+
/(?:FROM|INTO|TABLE(?: IF NOT EXISTS)?(?: IF EXISTS)?) (audit_entries(?:_rebuild)?)/.exec(
|
|
82
|
+
sql,
|
|
83
|
+
)?.[1] ?? "audit_entries"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private rowsIn(table: string): FakeRow[] {
|
|
88
|
+
const rows = this.tables.get(table);
|
|
89
|
+
if (rows) return rows;
|
|
90
|
+
const created: FakeRow[] = [];
|
|
91
|
+
this.tables.set(table, created);
|
|
92
|
+
return created;
|
|
93
|
+
}
|
|
94
|
+
|
|
73
95
|
exec<Row extends Record<string, AuditSqlValueV1>>(
|
|
74
96
|
query: string,
|
|
75
97
|
...bindings: unknown[]
|
|
@@ -77,7 +99,23 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
77
99
|
this.statements.push(query);
|
|
78
100
|
const sql = query.replace(/\s+/g, " ").trim();
|
|
79
101
|
const answer = (rows: unknown[]) => cursor(rows as Row[]);
|
|
102
|
+
const table = FakeAuditSql.table(sql);
|
|
80
103
|
|
|
104
|
+
const renamed = /^ALTER TABLE (\S+) RENAME TO (\S+)$/.exec(sql);
|
|
105
|
+
if (renamed) {
|
|
106
|
+
this.tables.set(renamed[2]!, this.rowsIn(renamed[1]!));
|
|
107
|
+
this.tables.delete(renamed[1]!);
|
|
108
|
+
return answer([]);
|
|
109
|
+
}
|
|
110
|
+
if (sql.startsWith("DROP TABLE")) {
|
|
111
|
+
this.tables.delete(table);
|
|
112
|
+
return answer([]);
|
|
113
|
+
}
|
|
114
|
+
if (sql.startsWith("CREATE TABLE")) {
|
|
115
|
+
if (!sql.includes("IF NOT EXISTS")) this.tables.set(table, []);
|
|
116
|
+
else this.rowsIn(table);
|
|
117
|
+
return answer([]);
|
|
118
|
+
}
|
|
81
119
|
if (sql.startsWith("CREATE") || sql.startsWith("DROP")) return answer([]);
|
|
82
120
|
|
|
83
121
|
if (sql.startsWith("SELECT value FROM audit_meta")) {
|
|
@@ -96,16 +134,16 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
96
134
|
const row = Object.fromEntries(
|
|
97
135
|
COLUMNS.map((column, index) => [column, bindings[index] ?? null]),
|
|
98
136
|
) as FakeRow;
|
|
99
|
-
this.
|
|
137
|
+
this.rowsIn(table).push(row);
|
|
100
138
|
return answer([]);
|
|
101
139
|
}
|
|
102
140
|
if (sql.startsWith("SELECT count(*) AS n FROM audit_entries")) {
|
|
103
|
-
return answer([{ n: this.filtered(sql, bindings).length }]);
|
|
141
|
+
return answer([{ n: this.filtered(table, sql, bindings).length }]);
|
|
104
142
|
}
|
|
105
143
|
if (sql.startsWith("SELECT bot_id, run_id, occurrence_id FROM")) {
|
|
106
144
|
const limit = Number(bindings[0]);
|
|
107
145
|
return answer(
|
|
108
|
-
[...this.
|
|
146
|
+
[...this.rowsIn(table)]
|
|
109
147
|
.sort(order)
|
|
110
148
|
.slice(0, limit)
|
|
111
149
|
.map((row) => ({
|
|
@@ -116,7 +154,10 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
116
154
|
);
|
|
117
155
|
}
|
|
118
156
|
if (sql.startsWith("DELETE FROM audit_entries WHERE at <")) {
|
|
119
|
-
this.
|
|
157
|
+
this.tables.set(
|
|
158
|
+
table,
|
|
159
|
+
this.rowsIn(table).filter((row) => row.at >= String(bindings[0])),
|
|
160
|
+
);
|
|
120
161
|
return answer([]);
|
|
121
162
|
}
|
|
122
163
|
if (
|
|
@@ -124,26 +165,32 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
124
165
|
"DELETE FROM audit_entries WHERE bot_id = ? AND run_id = ? AND occurrence_id = ?",
|
|
125
166
|
)
|
|
126
167
|
) {
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
168
|
+
this.tables.set(
|
|
169
|
+
table,
|
|
170
|
+
this.rowsIn(table).filter(
|
|
171
|
+
(row) =>
|
|
172
|
+
!(
|
|
173
|
+
row.bot_id === bindings[0] &&
|
|
174
|
+
row.run_id === bindings[1] &&
|
|
175
|
+
row.occurrence_id === bindings[2]
|
|
176
|
+
),
|
|
177
|
+
),
|
|
134
178
|
);
|
|
135
179
|
return answer([]);
|
|
136
180
|
}
|
|
137
181
|
if (sql.startsWith("DELETE FROM audit_entries WHERE bot_id = ?")) {
|
|
138
|
-
this.
|
|
182
|
+
this.tables.set(
|
|
183
|
+
table,
|
|
184
|
+
this.rowsIn(table).filter((row) => row.bot_id !== bindings[0]),
|
|
185
|
+
);
|
|
139
186
|
return answer([]);
|
|
140
187
|
}
|
|
141
188
|
if (sql === "DELETE FROM audit_entries") {
|
|
142
|
-
this.
|
|
189
|
+
this.tables.set(table, []);
|
|
143
190
|
return answer([]);
|
|
144
191
|
}
|
|
145
192
|
if (sql.startsWith("SELECT * FROM audit_entries")) {
|
|
146
|
-
const descending = [...this.filtered(sql, bindings)].sort(
|
|
193
|
+
const descending = [...this.filtered(table, sql, bindings)].sort(
|
|
147
194
|
(left, right) => -order(left, right),
|
|
148
195
|
);
|
|
149
196
|
if (!sql.includes("LIMIT")) return answer(descending);
|
|
@@ -155,11 +202,11 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
155
202
|
}
|
|
156
203
|
|
|
157
204
|
/** Applies the `WHERE bot_id/kind/target` clauses the store builds. */
|
|
158
|
-
private filtered(sql: string, bindings: unknown[]): FakeRow[] {
|
|
205
|
+
private filtered(table: string, sql: string, bindings: unknown[]): FakeRow[] {
|
|
159
206
|
const values = [...bindings];
|
|
160
207
|
const where = /WHERE (.+?)(?: ORDER BY| LIMIT|$)/.exec(sql)?.[1] ?? "";
|
|
161
208
|
if (where.startsWith("at <")) {
|
|
162
|
-
return this.
|
|
209
|
+
return this.rowsIn(table).filter((row) => row.at < String(values[0]));
|
|
163
210
|
}
|
|
164
211
|
const predicates: Array<(row: FakeRow) => boolean> = [];
|
|
165
212
|
for (const clause of where.split(" AND ").filter(Boolean)) {
|
|
@@ -168,7 +215,7 @@ export class FakeAuditSql implements AuditSqlV1 {
|
|
|
168
215
|
const expected = String(values.shift());
|
|
169
216
|
predicates.push((row) => String(row[column]) === expected);
|
|
170
217
|
}
|
|
171
|
-
return this.
|
|
218
|
+
return this.rowsIn(table).filter((row) =>
|
|
172
219
|
predicates.every((predicate) => predicate(row)),
|
|
173
220
|
);
|
|
174
221
|
}
|
package/src/user.test.ts
CHANGED
|
@@ -93,9 +93,25 @@ describe("the User Contribution", () => {
|
|
|
93
93
|
await expect(
|
|
94
94
|
audit.indexAuditEntries(Array.from({ length: 513 }, () => entry())),
|
|
95
95
|
).rejects.toThrow("bound");
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("quarantines one undecodable entry and indexes the rest", async () => {
|
|
99
|
+
const audit = contribution();
|
|
100
|
+
|
|
101
|
+
// The page carries one entry whose `turn` disagrees with its occurrence id
|
|
102
|
+
// — reachable in production through the tool-name length and slug mismatch
|
|
103
|
+
// between the classifier and the wire codec.
|
|
104
|
+
const receipt = await audit.indexAuditEntries([
|
|
105
|
+
entry(),
|
|
106
|
+
{ ...entry(), turn: 7 },
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Before this, the whole page threw. The throw was swallowed at the Bot's
|
|
110
|
+
// drain, the outbox was never drained again, and every later entry was
|
|
111
|
+
// dropped at the 512 bound: one malformed row cost the Bot its whole audit
|
|
112
|
+
// trail, silently.
|
|
113
|
+
expect(receipt).toEqual({ indexed: 1, quarantined: 1 });
|
|
114
|
+
expect(audit.query({}).entries).toHaveLength(1);
|
|
99
115
|
});
|
|
100
116
|
|
|
101
117
|
test("counts host-journal discrepancies without writing them in", async () => {
|
package/src/user.ts
CHANGED
|
@@ -115,17 +115,36 @@ export class AuditUserBackendContribution {
|
|
|
115
115
|
/**
|
|
116
116
|
* Idempotent on `(botId, runId, occurrenceId)`; a redelivered outbox page
|
|
117
117
|
* inserts nothing the second time.
|
|
118
|
+
*
|
|
119
|
+
* ONE BAD ENTRY IS ONE BAD ENTRY. Decoding used to be `input.map(decode)`,
|
|
120
|
+
* so a single undecodable entry threw for the whole page. The throw was
|
|
121
|
+
* swallowed at the Bot's drain, the outbox was never drained again, and
|
|
122
|
+
* every later entry was dropped at the 512 bound — one malformed row cost
|
|
123
|
+
* the Bot its entire audit trail, silently. Entries are decoded one at a
|
|
124
|
+
* time now: the bad one is quarantined and counted, the rest are indexed,
|
|
125
|
+
* and the page is accepted so the outbox clears.
|
|
118
126
|
*/
|
|
119
|
-
async indexAuditEntries(
|
|
127
|
+
async indexAuditEntries(
|
|
128
|
+
input: unknown,
|
|
129
|
+
): Promise<{ indexed: number; quarantined: number }> {
|
|
120
130
|
if (!Array.isArray(input)) {
|
|
121
131
|
throw new AuditDecodeError("audit entries must be an array");
|
|
122
132
|
}
|
|
123
133
|
if (input.length > AUDIT_MAX_ENTRY_PAGE_V1) {
|
|
124
134
|
throw new AuditDecodeError("audit entries exceed their bound");
|
|
125
135
|
}
|
|
126
|
-
const entries =
|
|
136
|
+
const entries: AuditEntryV1[] = [];
|
|
137
|
+
let quarantined = 0;
|
|
138
|
+
for (const candidate of input) {
|
|
139
|
+
try {
|
|
140
|
+
entries.push(decodeAuditEntryV1(candidate));
|
|
141
|
+
} catch {
|
|
142
|
+
quarantined += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
127
145
|
return {
|
|
128
146
|
indexed: this.store.insert(this.resolve(entries, await this.hosts())),
|
|
147
|
+
quarantined,
|
|
129
148
|
};
|
|
130
149
|
}
|
|
131
150
|
|