diff --git a/backend/src/lib/cosmos-init.ts b/backend/src/lib/cosmos-init.ts index 03b3a12..d736f0d 100644 --- a/backend/src/lib/cosmos-init.ts +++ b/backend/src/lib/cosmos-init.ts @@ -7,6 +7,8 @@ const CONTAINER_DEFS: Record = { 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 }, diff --git a/backend/src/modules/agent-actions/agent-actions.test.ts b/backend/src/modules/agent-actions/agent-actions.test.ts new file mode 100644 index 0000000..adda089 --- /dev/null +++ b/backend/src/modules/agent-actions/agent-actions.test.ts @@ -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); + }); +}); diff --git a/backend/src/modules/agent-actions/repository.ts b/backend/src/modules/agent-actions/repository.ts new file mode 100644 index 0000000..790210c --- /dev/null +++ b/backend/src/modules/agent-actions/repository.ts @@ -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('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, + offset: query.offset, + limit: query.limit, + }); + const total = await col.count(filter); + return { items, total }; +} + +export async function getAction( + id: string, + userId: string +): Promise { + return collection().findById(id, userId); +} + +export async function createAction(doc: AgentActionDoc): Promise { + return collection().create(doc); +} + +export async function updateActionState( + id: string, + userId: string, + state: AgentActionDoc['state'], + reviewedBy?: string +): Promise { + 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 }; +} diff --git a/backend/src/modules/agent-actions/routes.ts b/backend/src/modules/agent-actions/routes.ts new file mode 100644 index 0000000..dc02bd7 --- /dev/null +++ b/backend/src/modules/agent-actions/routes.ts @@ -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; + }); +} diff --git a/backend/src/modules/agent-actions/types.ts b/backend/src/modules/agent-actions/types.ts new file mode 100644 index 0000000..775f6df --- /dev/null +++ b/backend/src/modules/agent-actions/types.ts @@ -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; + + 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; +export type AgentActionQuery = z.infer; +export type BatchApproveInput = z.infer; diff --git a/backend/src/modules/timers/timers.test.ts b/backend/src/modules/timers/timers.test.ts index 7242a52..777ddbd 100644 --- a/backend/src/modules/timers/timers.test.ts +++ b/backend/src/modules/timers/timers.test.ts @@ -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); + }); +}); diff --git a/backend/src/server.ts b/backend/src/server.ts index 98c5d0e..8857216 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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 () => ({