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:
parent
686f5fb33e
commit
29a48025eb
@ -7,6 +7,8 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
routines: { partitionKeyPath: '/userId' },
|
routines: { partitionKeyPath: '/userId' },
|
||||||
households: { partitionKeyPath: '/id' },
|
households: { partitionKeyPath: '/id' },
|
||||||
shared_timers: { partitionKeyPath: '/householdId' },
|
shared_timers: { partitionKeyPath: '/householdId' },
|
||||||
|
// Agent actions (Phase A.2)
|
||||||
|
cm_agent_actions: { partitionKeyPath: '/userId' },
|
||||||
// Webhooks
|
// Webhooks
|
||||||
webhook_subscriptions: { partitionKeyPath: '/userId' },
|
webhook_subscriptions: { partitionKeyPath: '/userId' },
|
||||||
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },
|
webhook_events: { partitionKeyPath: '/subscriptionId', defaultTtl: 30 * 86400 },
|
||||||
|
|||||||
184
backend/src/modules/agent-actions/agent-actions.test.ts
Normal file
184
backend/src/modules/agent-actions/agent-actions.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
backend/src/modules/agent-actions/repository.ts
Normal file
95
backend/src/modules/agent-actions/repository.ts
Normal 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 };
|
||||||
|
}
|
||||||
115
backend/src/modules/agent-actions/routes.ts
Normal file
115
backend/src/modules/agent-actions/routes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
84
backend/src/modules/agent-actions/types.ts
Normal file
84
backend/src/modules/agent-actions/types.ts
Normal 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>;
|
||||||
@ -9,6 +9,8 @@ import {
|
|||||||
TimerQuerySchema,
|
TimerQuerySchema,
|
||||||
TimerSyncQuerySchema,
|
TimerSyncQuerySchema,
|
||||||
BatchUpsertSchema,
|
BatchUpsertSchema,
|
||||||
|
RescheduleTimerSchema,
|
||||||
|
AvailabilityQuerySchema,
|
||||||
TIMER_TYPES,
|
TIMER_TYPES,
|
||||||
TIMER_STATES,
|
TIMER_STATES,
|
||||||
URGENCY_LEVELS,
|
URGENCY_LEVELS,
|
||||||
@ -406,3 +408,132 @@ describe('BatchUpsertSchema', () => {
|
|||||||
expect(result.success).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { routineRoutes } from './modules/routines/routes.js';
|
|||||||
import { householdRoutes } from './modules/households/routes.js';
|
import { householdRoutes } from './modules/households/routes.js';
|
||||||
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
import { sharedTimerRoutes } from './modules/shared-timers/routes.js';
|
||||||
import { webhookRoutes } from './modules/webhooks/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 { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
import { initEncryption } from './lib/field-encrypt.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(householdRoutes, { prefix: '/api' });
|
||||||
await app.register(sharedTimerRoutes, { prefix: '/api' });
|
await app.register(sharedTimerRoutes, { prefix: '/api' });
|
||||||
await app.register(webhookRoutes, { prefix: '/api' });
|
await app.register(webhookRoutes, { prefix: '/api' });
|
||||||
|
await app.register(agentActionRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
// ── Bootstrap (no auth) ──────────────────────────────────────────
|
// ── Bootstrap (no auth) ──────────────────────────────────────────
|
||||||
app.get('/api/bootstrap', async () => ({
|
app.get('/api/bootstrap', async () => ({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user