Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-audit-append-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@agent-native/core": patch
---

Preserve audit event append order when timestamps are equal.
197 changes: 197 additions & 0 deletions packages/core/src/audit/store.postgres.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";

import type { AuditEvent } from "./types.js";

const postgresUrl = process.env.AGENT_NATIVE_AUDIT_POSTGRES_URL;

describe.skipIf(!postgresUrl)("audit store PostgreSQL append order", () => {
let dbModule: typeof import("../db/client.js");
let ddlModule: typeof import("../db/ddl-guard.js");
let store: typeof import("./store.js");

const event = (id: string, createdAt = 700): AuditEvent => ({
id,
createdAt,
action: "update-schedule",
caller: "tool",
actorKind: "agent",
actorEmail: "alice@example.test",
orgId: null,
threadId: null,
turnId: null,
targetType: "automation",
targetId: "automation-1",
status: "success",
summary: null,
input: null,
errorCode: null,
ownerEmail: "alice@example.test",
visibility: "private",
});

async function resetEphemeralSchema(): Promise<void> {
const client = dbModule.getDbExec();
await client.execute("DROP TABLE IF EXISTS agent_audit_log CASCADE");
await client.execute(
"DROP TABLE IF EXISTS agent_audit_append_order CASCADE",
);
await client.execute(
"DROP FUNCTION IF EXISTS agent_audit_allocate_append_order() CASCADE",
);
await dbModule.closeDbExec();
ddlModule.__resetSchemaSnapshotForTests();
store.__resetAuditInitForTests();
}

beforeAll(async () => {
const parsed = new URL(postgresUrl!);
expect(["127.0.0.1", "localhost", "::1"]).toContain(parsed.hostname);
expect(parsed.pathname).toBe("/audit_test");
vi.stubEnv("DATABASE_URL", postgresUrl!);

dbModule = await import("../db/client.js");
ddlModule = await import("../db/ddl-guard.js");
store = await import("./store.js");

const version = await dbModule.getDbExec().execute("SHOW server_version");
expect(String(version.rows[0]?.server_version)).toMatch(/^17\./);
});

beforeEach(resetEphemeralSchema);

afterAll(async () => {
if (dbModule) {
await resetEphemeralSchema();
await dbModule.closeDbExec();
}
vi.unstubAllEnvs();
});

it("returns exact tied-timestamp pages in append order", async () => {
await store.insertAuditEvent(event("z-first"));
await store.insertAuditEvent(event("m-second"));
await store.insertAuditEvent(event("a-third"));

const firstPage = await store.queryAuditEvents(
{ userEmail: "alice@example.test" },
{ limit: 2 },
);
const secondPage = await store.queryAuditEvents(
{ userEmail: "alice@example.test" },
{ limit: 2, offset: 2 },
);

expect(firstPage.map((row) => row.id)).toEqual(["a-third", "m-second"]);
expect(secondPage.map((row) => row.id)).toEqual(["z-first"]);
const union = [...firstPage, ...secondPage];
expect(union.map((row) => row.id)).toEqual([
"a-third",
"m-second",
"z-first",
]);
expect(new Set(union.map((row) => row.id)).size).toBe(3);
expect(union.every((row) => !("append_order" in row))).toBe(true);
});

it("backfills a legacy table and allocates for rolling old writers", async () => {
const client = dbModule.getDbExec();
await client.execute(`
CREATE TABLE agent_audit_log (
id TEXT PRIMARY KEY,
created_at BIGINT NOT NULL,
action TEXT NOT NULL,
caller TEXT NOT NULL,
actor_kind TEXT NOT NULL,
actor_email TEXT,
org_id TEXT,
thread_id TEXT,
turn_id TEXT,
target_type TEXT,
target_id TEXT,
status TEXT NOT NULL DEFAULT 'success',
summary TEXT,
input TEXT,
error_code TEXT,
owner_email TEXT,
visibility TEXT NOT NULL DEFAULT 'private'
)
`);
for (const id of ["legacy-z", "legacy-m", "legacy-a"]) {
await client.execute({
sql: `INSERT INTO agent_audit_log
(id, created_at, action, caller, actor_kind, owner_email, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
id,
700,
"legacy",
"tool",
"agent",
"alice@example.test",
"private",
],
});
}

store.__resetAuditInitForTests();
await store.ensureAuditTables();

const migrated = await client.execute(
"SELECT id, append_order FROM agent_audit_log ORDER BY append_order",
);
expect(migrated.rows.map((row) => row.id)).toEqual([
"legacy-z",
"legacy-m",
"legacy-a",
]);
expect(migrated.rows.map((row) => Number(row.append_order))).toEqual([
1, 2, 3,
]);

// This is the pre-upgrade INSERT shape: it omits append_order entirely.
await client.execute({
sql: `INSERT INTO agent_audit_log
(id, created_at, action, caller, actor_kind, owner_email, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
"a-after-upgrade",
700,
"legacy-writer",
"tool",
"agent",
"alice@example.test",
"private",
],
});
const state = await client.execute(`
SELECT COUNT(*)::INT AS total,
COUNT(append_order)::INT AS non_null,
COUNT(DISTINCT append_order)::INT AS unique_count,
MAX(append_order)::BIGINT AS max_order
FROM agent_audit_log
`);
expect(state.rows[0]).toMatchObject({
total: 4,
non_null: 4,
unique_count: 4,
max_order: "4",
});
const rows = await store.queryAuditEvents({
userEmail: "alice@example.test",
});
expect(rows.map((row) => row.id)).toEqual([
"a-after-upgrade",
"legacy-a",
"legacy-m",
"legacy-z",
]);
});
});
98 changes: 97 additions & 1 deletion packages/core/src/audit/store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const rawClient = {
}
const stmt = sqlite.prepare(input.sql);
const args = (input.args ?? []) as unknown[];
if (/^\s*select/i.test(input.sql)) {
if (stmt.reader) {
return { rows: stmt.all(...args), rowsAffected: 0 };
}
const info = stmt.run(...args);
Expand Down Expand Up @@ -207,6 +207,102 @@ describe("audit store filters + ordering", () => {
expect(limited).toHaveLength(2);
});

it("uses append order for exact tied-timestamp pages", async () => {
const createdAt = 700;
await insertAuditEvent(makeEvent({ id: "z-first", createdAt }));
await insertAuditEvent(makeEvent({ id: "m-second", createdAt }));
await insertAuditEvent(makeEvent({ id: "a-third", createdAt }));

const firstPage = await queryAuditEvents(
{ userEmail: "alice@x.com" },
{ limit: 2 },
);
const secondPage = await queryAuditEvents(
{ userEmail: "alice@x.com" },
{ limit: 2, offset: 2 },
);

expect(firstPage.map((row) => row.id)).toEqual(["a-third", "m-second"]);
expect(secondPage.map((row) => row.id)).toEqual(["z-first"]);
expect([...firstPage, ...secondPage].map((row) => row.id)).toEqual([
"a-third",
"m-second",
"z-first",
]);
expect(
new Set([...firstPage, ...secondPage].map((row) => row.id)).size,
).toBe(3);
});

it("initializes append order for a legacy SQLite table", async () => {
sqlite.close();
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE agent_audit_log (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
action TEXT NOT NULL,
caller TEXT NOT NULL,
actor_kind TEXT NOT NULL,
actor_email TEXT,
org_id TEXT,
thread_id TEXT,
turn_id TEXT,
target_type TEXT,
target_id TEXT,
status TEXT NOT NULL DEFAULT 'success',
summary TEXT,
input TEXT,
error_code TEXT,
owner_email TEXT,
visibility TEXT NOT NULL DEFAULT 'private'
);
INSERT INTO agent_audit_log
(id, created_at, action, caller, actor_kind, owner_email, visibility)
VALUES
('legacy-z', 700, 'legacy', 'tool', 'agent', 'alice@x.com', 'private'),
('legacy-m', 700, 'legacy', 'tool', 'agent', 'alice@x.com', 'private'),
('legacy-a', 700, 'legacy', 'tool', 'agent', 'alice@x.com', 'private');
`);
__resetAuditInitForTests();

await ensureAuditTables();
const migrated = sqlite
.prepare(
"SELECT id, append_order FROM agent_audit_log ORDER BY append_order",
)
.all() as Array<{ id: string; append_order: number }>;
expect(migrated).toEqual([
{ id: "legacy-z", append_order: 1 },
{ id: "legacy-m", append_order: 2 },
{ id: "legacy-a", append_order: 3 },
]);

await insertAuditEvent(
makeEvent({ id: "a-after-upgrade", createdAt: 700 }),
);
const rows = await queryAuditEvents({ userEmail: "alice@x.com" });
expect(rows.map((row) => row.id)).toEqual([
"a-after-upgrade",
"legacy-a",
"legacy-m",
"legacy-z",
]);
const nullOrDuplicate = sqlite
.prepare(
`SELECT COUNT(*) AS total,
COUNT(append_order) AS non_null,
COUNT(DISTINCT append_order) AS unique_count
FROM agent_audit_log`,
)
.get() as { total: number; non_null: number; unique_count: number };
expect(nullOrDuplicate).toEqual({
total: 4,
non_null: 4,
unique_count: 4,
});
});

it("filters by sinceMs", async () => {
await insertAuditEvent(makeEvent({ createdAt: 100 }));
await insertAuditEvent(makeEvent({ createdAt: 500 }));
Expand Down
Loading
Loading