feat(agent-actions): Phase A.2 — agent action audit trail module + tests

Add agent-actions module for AI/MCP operation audit trail:

- types.ts: 3 enums (ACTOR_TYPES, ACTION_STATES, ACTION_TYPES), AgentActionDoc,
  CreateAgentActionSchema, AgentActionQuerySchema, BatchApproveSchema
- repository.ts: CRUD + batchApproveByActor using @bytelyst/datastore
- routes.ts: 5 endpoints (list, create, approve, reject, batch-approve)
  All gated behind isFeatureEnabled('agent_inbox.enabled')
- Registered cm_agent_actions container in cosmos-init.ts
- Registered agentActionRoutes in server.ts

Tests (37 new, 219 total):
- 22 agent-actions schema tests (constants, create, query, batch-approve)
- 15 timer schema tests (RescheduleTimerSchema, AvailabilityQuerySchema)

All 219 backend tests pass. No breaking changes.
This commit is contained in:
saravanakumardb1 2026-03-31 23:38:03 -07:00
parent 686f5fb33e
commit 29a48025eb
7 changed files with 613 additions and 0 deletions

View File

@ -7,6 +7,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
routines: { partitionKeyPath: '/userId' },
households: { partitionKeyPath: '/id' },
shared_timers: { partitionKeyPath: '/householdId' },
// Agent actions (Phase A.2)
cm_agent_actions: { partitionKeyPath: '/userId' },
// Webhooks
webhook_subscriptions: { partitionKeyPath: '/userId' },
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },

View File

@ -0,0 +1,184 @@
/**
* Agent Actions module unit tests validates schemas, constants, and type guards.
*/
import { describe, it, expect } from 'vitest';
import {
CreateAgentActionSchema,
AgentActionQuerySchema,
BatchApproveSchema,
ACTOR_TYPES,
ACTION_STATES,
ACTION_TYPES,
} from './types.js';
// ── Constants ──
describe('agent action constants', () => {
it('has 3 actor types', () => {
expect(ACTOR_TYPES).toEqual(['agent', 'user', 'mcp']);
});
it('has 4 action states', () => {
expect(ACTION_STATES).toEqual(['proposed', 'approved', 'applied', 'rejected']);
});
it('has 8 action types', () => {
expect(ACTION_TYPES).toHaveLength(8);
expect(ACTION_TYPES).toContain('timer.create');
expect(ACTION_TYPES).toContain('timer.reschedule');
expect(ACTION_TYPES).toContain('routine.start');
expect(ACTION_TYPES).toContain('planner.apply');
});
});
// ── CreateAgentActionSchema ──
describe('CreateAgentActionSchema', () => {
const validMinimal = {
id: 'action_001',
actorId: 'mcp-agent-1',
actorType: 'mcp' as const,
toolName: 'chronomind.timers.reschedule',
actionType: 'timer.reschedule' as const,
};
it('accepts minimal valid input with defaults', () => {
const result = CreateAgentActionSchema.safeParse(validMinimal);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe('action_001');
expect(result.data.actorId).toBe('mcp-agent-1');
expect(result.data.actorType).toBe('mcp');
expect(result.data.state).toBe('proposed');
expect(result.data.payload).toEqual({});
}
});
it('accepts full input with reason and payload', () => {
const result = CreateAgentActionSchema.safeParse({
...validMinimal,
state: 'proposed',
reason: 'Timer conflicts with meeting',
payload: { timerId: 'timer_42', deltaSeconds: 900 },
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.reason).toBe('Timer conflicts with meeting');
expect(result.data.payload).toEqual({ timerId: 'timer_42', deltaSeconds: 900 });
}
});
it('rejects empty id', () => {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, id: '' });
expect(result.success).toBe(false);
});
it('rejects empty actorId', () => {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, actorId: '' });
expect(result.success).toBe(false);
});
it('rejects invalid actorType', () => {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, actorType: 'bot' });
expect(result.success).toBe(false);
});
it('rejects invalid actionType', () => {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, actionType: 'unknown.action' });
expect(result.success).toBe(false);
});
it('rejects invalid state', () => {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, state: 'pending' });
expect(result.success).toBe(false);
});
it('rejects reason over 2000 chars', () => {
const result = CreateAgentActionSchema.safeParse({
...validMinimal,
reason: 'x'.repeat(2001),
});
expect(result.success).toBe(false);
});
it('accepts all valid actorTypes', () => {
for (const at of ACTOR_TYPES) {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, actorType: at });
expect(result.success).toBe(true);
}
});
it('accepts all valid actionTypes', () => {
for (const at of ACTION_TYPES) {
const result = CreateAgentActionSchema.safeParse({ ...validMinimal, actionType: at });
expect(result.success).toBe(true);
}
});
});
// ── AgentActionQuerySchema ──
describe('AgentActionQuerySchema', () => {
it('accepts empty query with defaults', () => {
const result = AgentActionQuerySchema.safeParse({});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sortBy).toBe('createdAt');
expect(result.data.sortOrder).toBe('desc');
expect(result.data.limit).toBe(50);
expect(result.data.offset).toBe(0);
}
});
it('accepts state filter', () => {
const result = AgentActionQuerySchema.safeParse({ state: 'proposed' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.state).toBe('proposed');
}
});
it('accepts actorId filter', () => {
const result = AgentActionQuerySchema.safeParse({ actorId: 'mcp-agent-1' });
expect(result.success).toBe(true);
});
it('accepts toolName filter', () => {
const result = AgentActionQuerySchema.safeParse({ toolName: 'chronomind.timers.reschedule' });
expect(result.success).toBe(true);
});
it('rejects invalid state', () => {
const result = AgentActionQuerySchema.safeParse({ state: 'unknown' });
expect(result.success).toBe(false);
});
it('coerces limit and offset from strings', () => {
const result = AgentActionQuerySchema.safeParse({ limit: '20', offset: '5' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.limit).toBe(20);
expect(result.data.offset).toBe(5);
}
});
});
// ── BatchApproveSchema ──
describe('BatchApproveSchema', () => {
it('accepts valid actorId', () => {
const result = BatchApproveSchema.safeParse({ actorId: 'mcp-agent-1' });
expect(result.success).toBe(true);
});
it('rejects empty actorId', () => {
const result = BatchApproveSchema.safeParse({ actorId: '' });
expect(result.success).toBe(false);
});
it('rejects missing actorId', () => {
const result = BatchApproveSchema.safeParse({});
expect(result.success).toBe(false);
});
});

View File

@ -0,0 +1,95 @@
/**
* Agent Actions repository cloud-agnostic CRUD.
*
* Container: cm_agent_actions (partition key: /userId)
*/
import { getCollection } from '../../lib/datastore.js';
import type { AgentActionDoc, AgentActionQuery } from './types.js';
import type { FilterMap } from '@bytelyst/datastore';
function collection() {
return getCollection<AgentActionDoc>('cm_agent_actions', '/userId');
}
export async function listActions(
userId: string,
productId: string,
query: AgentActionQuery
): Promise<{ items: AgentActionDoc[]; total: number }> {
const filter: FilterMap = { userId, productId };
if (query.state) filter.state = query.state;
if (query.actorId) filter.actorId = query.actorId;
if (query.actorType) filter.actorType = query.actorType;
if (query.toolName) filter.toolName = query.toolName;
if (query.actionType) filter.actionType = query.actionType;
const col = collection();
const sortDir = query.sortOrder === 'asc' ? 1 : -1;
const items = await col.findMany({
filter,
sort: { [query.sortBy]: sortDir } as Record<string, 1 | -1>,
offset: query.offset,
limit: query.limit,
});
const total = await col.count(filter);
return { items, total };
}
export async function getAction(
id: string,
userId: string
): Promise<AgentActionDoc | null> {
return collection().findById(id, userId);
}
export async function createAction(doc: AgentActionDoc): Promise<AgentActionDoc> {
return collection().create(doc);
}
export async function updateActionState(
id: string,
userId: string,
state: AgentActionDoc['state'],
reviewedBy?: string
): Promise<AgentActionDoc | null> {
const existing = await collection().findById(id, userId);
if (!existing) return null;
const updated: AgentActionDoc = {
...existing,
state,
reviewedAt: new Date().toISOString(),
reviewedBy: reviewedBy ?? existing.userId,
};
return collection().update(id, userId, updated);
}
export async function batchApproveByActor(
userId: string,
productId: string,
actorId: string
): Promise<{ approved: string[] }> {
const { items } = await listActions(userId, productId, {
state: 'proposed',
actorId,
sortBy: 'createdAt',
sortOrder: 'desc',
limit: 100,
offset: 0,
});
const approved: string[] = [];
const now = new Date().toISOString();
for (const action of items) {
const updated: AgentActionDoc = {
...action,
state: 'approved',
reviewedAt: now,
reviewedBy: userId,
};
await collection().update(action.id, userId, updated);
approved.push(action.id);
}
return { approved };
}

View File

@ -0,0 +1,115 @@
/**
* Agent Action REST endpoints audit trail for AI/MCP operations.
*
* GET /agent-actions list actions (filterable by state, actorId, toolName)
* POST /agent-actions create action (used by MCP tools)
* POST /agent-actions/:id/approve approve a proposed action
* POST /agent-actions/:id/reject reject a proposed action
* POST /agent-actions/batch-approve batch approve by actorId
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { extractAuth } from '../../lib/auth.js';
import { isFeatureEnabled } from '../../lib/feature-flags.js';
import * as repo from './repository.js';
import {
CreateAgentActionSchema,
AgentActionQuerySchema,
BatchApproveSchema,
type AgentActionDoc,
} from './types.js';
const PRODUCT_ID = 'chronomind';
export async function agentActionRoutes(app: FastifyInstance) {
// ── Feature flag gate ─────────────────────────────────────
app.addHook('onRequest', async (_req, reply) => {
if (!isFeatureEnabled('agent_inbox.enabled')) {
reply.code(400);
throw new BadRequestError('Agent actions are not enabled');
}
});
// List agent actions
app.get('/agent-actions', async req => {
const auth = await extractAuth(req);
const parsed = AgentActionQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const { items, total } = await repo.listActions(auth.sub, PRODUCT_ID, parsed.data);
return { items, total, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Create agent action (used by MCP tools to record proposed actions)
app.post('/agent-actions', async (req, reply) => {
const auth = await extractAuth(req);
const parsed = CreateAgentActionSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const now = new Date().toISOString();
const doc: AgentActionDoc = {
...parsed.data,
userId: auth.sub,
productId: PRODUCT_ID,
createdAt: now,
};
req.log.info({ actionId: doc.id, actorId: doc.actorId, toolName: doc.toolName }, 'Creating agent action');
const created = await repo.createAction(doc);
reply.code(201);
return created;
});
// Approve a proposed action
app.post('/agent-actions/:id/approve', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const action = await repo.getAction(id, auth.sub);
if (!action) throw new NotFoundError('Agent action not found');
if (action.state !== 'proposed') {
throw new BadRequestError(`Cannot approve action in state "${action.state}" — must be "proposed"`);
}
const updated = await repo.updateActionState(id, auth.sub, 'approved', auth.sub);
if (!updated) throw new NotFoundError('Agent action not found');
req.log.info({ actionId: id }, 'Approved agent action');
return updated;
});
// Reject a proposed action
app.post('/agent-actions/:id/reject', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
const action = await repo.getAction(id, auth.sub);
if (!action) throw new NotFoundError('Agent action not found');
if (action.state !== 'proposed') {
throw new BadRequestError(`Cannot reject action in state "${action.state}" — must be "proposed"`);
}
const updated = await repo.updateActionState(id, auth.sub, 'rejected', auth.sub);
if (!updated) throw new NotFoundError('Agent action not found');
req.log.info({ actionId: id }, 'Rejected agent action');
return updated;
});
// Batch approve all proposed actions from a specific actor
app.post('/agent-actions/batch-approve', async req => {
const auth = await extractAuth(req);
const parsed = BatchApproveSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const result = await repo.batchApproveByActor(auth.sub, PRODUCT_ID, parsed.data.actorId);
req.log.info({ actorId: parsed.data.actorId, count: result.approved.length }, 'Batch approved agent actions');
return result;
});
}

View File

@ -0,0 +1,84 @@
/**
* Agent Action types audit trail for AI/MCP operations.
*
* Cosmos container: `cm_agent_actions` (partition key: `/userId`)
* Product ID: "chronomind"
*/
import { z } from 'zod';
// ── Enums / constants ──
export const ACTOR_TYPES = ['agent', 'user', 'mcp'] as const;
export type ActorType = (typeof ACTOR_TYPES)[number];
export const ACTION_STATES = ['proposed', 'approved', 'applied', 'rejected'] as const;
export type ActionState = (typeof ACTION_STATES)[number];
export const ACTION_TYPES = [
'timer.create',
'timer.reschedule',
'timer.delete',
'timer.dismiss',
'routine.start',
'routine.create',
'planner.apply',
'context.enrich',
] as const;
export type ActionType = (typeof ACTION_TYPES)[number];
// ── Main document ──
export interface AgentActionDoc {
id: string;
userId: string;
productId: string;
actorId: string;
actorType: ActorType;
toolName: string;
actionType: ActionType;
state: ActionState;
reason?: string;
payload: Record<string, unknown>;
createdAt: string;
reviewedAt?: string;
reviewedBy?: string;
}
// ── Zod schemas ──
export const CreateAgentActionSchema = z.object({
id: z.string().min(1).max(128),
actorId: z.string().min(1).max(256),
actorType: z.enum(ACTOR_TYPES),
toolName: z.string().min(1).max(256),
actionType: z.enum(ACTION_TYPES),
state: z.enum(ACTION_STATES).default('proposed'),
reason: z.string().max(2000).optional(),
payload: z.record(z.unknown()).default({}),
});
export const AgentActionQuerySchema = z.object({
state: z.enum(ACTION_STATES).optional(),
actorId: z.string().optional(),
actorType: z.enum(ACTOR_TYPES).optional(),
toolName: z.string().optional(),
actionType: z.enum(ACTION_TYPES).optional(),
sortBy: z.enum(['createdAt']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export const BatchApproveSchema = z.object({
actorId: z.string().min(1).max(256),
});
// ── Inferred types ──
export type CreateAgentActionInput = z.infer<typeof CreateAgentActionSchema>;
export type AgentActionQuery = z.infer<typeof AgentActionQuerySchema>;
export type BatchApproveInput = z.infer<typeof BatchApproveSchema>;

View File

@ -9,6 +9,8 @@ import {
TimerQuerySchema,
TimerSyncQuerySchema,
BatchUpsertSchema,
RescheduleTimerSchema,
AvailabilityQuerySchema,
TIMER_TYPES,
TIMER_STATES,
URGENCY_LEVELS,
@ -406,3 +408,132 @@ describe('BatchUpsertSchema', () => {
expect(result.success).toBe(false);
});
});
// ── RescheduleTimerSchema (Phase A.1) ──
describe('RescheduleTimerSchema', () => {
it('accepts deltaSeconds only', () => {
const result = RescheduleTimerSchema.safeParse({ deltaSeconds: 900, syncVersion: 2 });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.deltaSeconds).toBe(900);
expect(result.data.targetTime).toBeUndefined();
}
});
it('accepts targetTime only', () => {
const result = RescheduleTimerSchema.safeParse({
targetTime: '2026-03-01T08:00:00.000Z',
syncVersion: 2,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.targetTime).toBe('2026-03-01T08:00:00.000Z');
expect(result.data.deltaSeconds).toBeUndefined();
}
});
it('rejects both deltaSeconds and targetTime', () => {
const result = RescheduleTimerSchema.safeParse({
deltaSeconds: 900,
targetTime: '2026-03-01T08:00:00.000Z',
syncVersion: 2,
});
expect(result.success).toBe(false);
});
it('rejects neither deltaSeconds nor targetTime', () => {
const result = RescheduleTimerSchema.safeParse({ syncVersion: 2 });
expect(result.success).toBe(false);
});
it('accepts negative deltaSeconds (earlier)', () => {
const result = RescheduleTimerSchema.safeParse({ deltaSeconds: -600, syncVersion: 1 });
expect(result.success).toBe(true);
});
it('rejects missing syncVersion', () => {
const result = RescheduleTimerSchema.safeParse({ deltaSeconds: 900 });
expect(result.success).toBe(false);
});
it('rejects syncVersion < 1', () => {
const result = RescheduleTimerSchema.safeParse({ deltaSeconds: 900, syncVersion: 0 });
expect(result.success).toBe(false);
});
it('rejects invalid targetTime format', () => {
const result = RescheduleTimerSchema.safeParse({
targetTime: 'not-a-date',
syncVersion: 2,
});
expect(result.success).toBe(false);
});
});
// ── AvailabilityQuerySchema (Phase A.1) ──
describe('AvailabilityQuerySchema', () => {
it('accepts valid start/end with default minSlotMinutes', () => {
const result = AvailabilityQuerySchema.safeParse({
start: '2026-03-01T08:00:00.000Z',
end: '2026-03-01T18:00:00.000Z',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.minSlotMinutes).toBe(15);
}
});
it('accepts custom minSlotMinutes', () => {
const result = AvailabilityQuerySchema.safeParse({
start: '2026-03-01T08:00:00.000Z',
end: '2026-03-01T18:00:00.000Z',
minSlotMinutes: '30',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.minSlotMinutes).toBe(30);
}
});
it('rejects missing start', () => {
const result = AvailabilityQuerySchema.safeParse({
end: '2026-03-01T18:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects missing end', () => {
const result = AvailabilityQuerySchema.safeParse({
start: '2026-03-01T08:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects invalid start format', () => {
const result = AvailabilityQuerySchema.safeParse({
start: 'not-a-date',
end: '2026-03-01T18:00:00.000Z',
});
expect(result.success).toBe(false);
});
it('rejects minSlotMinutes < 1', () => {
const result = AvailabilityQuerySchema.safeParse({
start: '2026-03-01T08:00:00.000Z',
end: '2026-03-01T18:00:00.000Z',
minSlotMinutes: '0',
});
expect(result.success).toBe(false);
});
it('rejects minSlotMinutes > 480', () => {
const result = AvailabilityQuerySchema.safeParse({
start: '2026-03-01T08:00:00.000Z',
end: '2026-03-01T18:00:00.000Z',
minSlotMinutes: '481',
});
expect(result.success).toBe(false);
});
});

View File

@ -12,6 +12,7 @@ import { routineRoutes } from './modules/routines/routes.js';
import { householdRoutes } from './modules/households/routes.js';
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
import { webhookRoutes } from './modules/webhooks/routes.js';
import { agentActionRoutes } from './modules/agent-actions/routes.js';
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
import { initDatastore } from './lib/datastore.js';
import { initEncryption } from './lib/field-encrypt.js';
@ -53,6 +54,7 @@ await app.register(routineRoutes, { prefix: '/api' });
await app.register(householdRoutes, { prefix: '/api' });
await app.register(sharedTimerRoutes, { prefix: '/api' });
await app.register(webhookRoutes, { prefix: '/api' });
await app.register(agentActionRoutes, { prefix: '/api' });
// ── Bootstrap (no auth) ──────────────────────────────────────────
app.get('/api/bootstrap', async () => ({