feat(mcp): Phase A.1 — reschedule, availability, and start-routine endpoints
Add 3 new backend REST endpoints for MCP tool support:
- POST /timers/:id/reschedule — shift timer by delta seconds or set new target time
- GET /timers/availability — find free time slots in a window (interval merge algorithm)
- POST /routines/:id/start — transition routine from ready/template → active
All endpoints gated behind isFeatureEnabled('mcp.enabled') flag (default false).
Zod schemas: RescheduleTimerSchema (XOR validation), AvailabilityQuerySchema, FreeSlot type.
All 182 backend tests pass. No breaking changes to existing APIs.
This commit is contained in:
parent
f3e14e28dd
commit
686f5fb33e
@ -13,6 +13,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors';
|
import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors';
|
||||||
import { extractAuth } from '../../lib/auth.js';
|
import { extractAuth } from '../../lib/auth.js';
|
||||||
|
import { isFeatureEnabled } from '../../lib/feature-flags.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import {
|
import {
|
||||||
CreateRoutineSchema,
|
CreateRoutineSchema,
|
||||||
@ -133,6 +134,55 @@ export async function routineRoutes(app: FastifyInstance) {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase A.1: Start routine ────────────────────────────────
|
||||||
|
// POST /routines/:id/start — transition a routine from ready/template → active
|
||||||
|
app.post('/routines/:id/start', async req => {
|
||||||
|
if (!isFeatureEnabled('mcp.enabled')) {
|
||||||
|
throw new BadRequestError('Start routine is not enabled');
|
||||||
|
}
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
|
||||||
|
const routine = await repo.getRoutine(id, auth.sub);
|
||||||
|
if (!routine) throw new NotFoundError('Routine not found');
|
||||||
|
if (routine.productId !== PRODUCT_ID) throw new NotFoundError('Routine not found');
|
||||||
|
|
||||||
|
if (routine.status !== 'ready' && routine.status !== 'template') {
|
||||||
|
throw new BadRequestError(
|
||||||
|
`Cannot start routine in status "${routine.status}" — must be "ready" or "template"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
// TODO(AGENTIC-5): When starting from a template, consider cloning instead of
|
||||||
|
// mutating the template directly. For now we transition in-place.
|
||||||
|
const result = await repo.updateRoutine(
|
||||||
|
id,
|
||||||
|
auth.sub,
|
||||||
|
{
|
||||||
|
status: 'active' as const,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
startedAt: now,
|
||||||
|
steps: routine.steps.map((step, i) => ({
|
||||||
|
...step,
|
||||||
|
status: i === 0 ? 'active' as const : 'pending' as const,
|
||||||
|
startedAt: i === 0 ? now : undefined,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
routine.syncVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.conflict) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Sync conflict: server version is ${result.serverVersion}, received ${routine.syncVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.doc) throw new NotFoundError('Routine not found');
|
||||||
|
|
||||||
|
req.log.info({ routineId: id }, 'Started routine');
|
||||||
|
return result.doc;
|
||||||
|
});
|
||||||
|
|
||||||
// Batch upsert
|
// Batch upsert
|
||||||
app.post('/routines/batch', async req => {
|
app.post('/routines/batch', async req => {
|
||||||
const auth = await extractAuth(req);
|
const auth = await extractAuth(req);
|
||||||
|
|||||||
@ -14,13 +14,17 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors';
|
import { BadRequestError, NotFoundError, ConflictError } from '@bytelyst/errors';
|
||||||
import { extractAuth } from '../../lib/auth.js';
|
import { extractAuth } from '../../lib/auth.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { isFeatureEnabled } from '../../lib/feature-flags.js';
|
||||||
import {
|
import {
|
||||||
CreateTimerSchema,
|
CreateTimerSchema,
|
||||||
UpdateTimerSchema,
|
UpdateTimerSchema,
|
||||||
TimerQuerySchema,
|
TimerQuerySchema,
|
||||||
TimerSyncQuerySchema,
|
TimerSyncQuerySchema,
|
||||||
BatchUpsertSchema,
|
BatchUpsertSchema,
|
||||||
|
RescheduleTimerSchema,
|
||||||
|
AvailabilityQuerySchema,
|
||||||
type TimerDoc,
|
type TimerDoc,
|
||||||
|
type FreeSlot,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
const PRODUCT_ID = 'chronomind';
|
const PRODUCT_ID = 'chronomind';
|
||||||
@ -164,6 +168,135 @@ export async function timerRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase A.1: Reschedule timer ─────────────────────────────
|
||||||
|
// POST /timers/:id/reschedule — shift by delta or set new target time
|
||||||
|
app.post('/timers/:id/reschedule', async req => {
|
||||||
|
if (!isFeatureEnabled('mcp.enabled')) {
|
||||||
|
throw new BadRequestError('Reschedule is not enabled');
|
||||||
|
}
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
|
||||||
|
const parsed = RescheduleTimerSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = await repo.getTimer(id, auth.sub);
|
||||||
|
if (!timer) throw new NotFoundError('Timer not found');
|
||||||
|
if (timer.productId !== PRODUCT_ID) throw new NotFoundError('Timer not found');
|
||||||
|
|
||||||
|
let newTargetTime: string;
|
||||||
|
if (parsed.data.targetTime) {
|
||||||
|
newTargetTime = parsed.data.targetTime;
|
||||||
|
} else {
|
||||||
|
const current = new Date(timer.targetTime).getTime();
|
||||||
|
newTargetTime = new Date(current + parsed.data.deltaSeconds! * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await repo.updateTimer(
|
||||||
|
id,
|
||||||
|
auth.sub,
|
||||||
|
{ targetTime: newTargetTime },
|
||||||
|
parsed.data.syncVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.conflict) {
|
||||||
|
throw new ConflictError(
|
||||||
|
`Sync conflict: server version is ${result.serverVersion}, received ${parsed.data.syncVersion}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!result.doc) throw new NotFoundError('Timer not found');
|
||||||
|
|
||||||
|
req.log.info({ timerId: id, newTargetTime }, 'Rescheduled timer');
|
||||||
|
return result.doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Phase A.1: Timer availability ──────────────────────────
|
||||||
|
// GET /timers/availability?start=<ISO>&end=<ISO>&minSlotMinutes=<N>
|
||||||
|
// Returns free time slots within the given window.
|
||||||
|
app.get('/timers/availability', async req => {
|
||||||
|
if (!isFeatureEnabled('mcp.enabled')) {
|
||||||
|
throw new BadRequestError('Availability check is not enabled');
|
||||||
|
}
|
||||||
|
const auth = await extractAuth(req);
|
||||||
|
|
||||||
|
const parsed = AvailabilityQuerySchema.safeParse(req.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowStart = new Date(parsed.data.start).getTime();
|
||||||
|
const windowEnd = new Date(parsed.data.end).getTime();
|
||||||
|
if (windowEnd <= windowStart) {
|
||||||
|
throw new BadRequestError('end must be after start');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all timers in the window (active or upcoming)
|
||||||
|
const { items: timers } = await repo.listTimers(auth.sub, PRODUCT_ID, {
|
||||||
|
limit: 100,
|
||||||
|
offset: 0,
|
||||||
|
sortBy: 'targetTime',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build occupied intervals from timers that overlap the window
|
||||||
|
const occupied: Array<{ start: number; end: number }> = [];
|
||||||
|
for (const t of timers) {
|
||||||
|
const tTarget = new Date(t.targetTime).getTime();
|
||||||
|
const tStart = t.startedAt ? new Date(t.startedAt).getTime() : tTarget - t.duration * 1000;
|
||||||
|
const tEnd = tTarget;
|
||||||
|
// Only include if it overlaps the query window
|
||||||
|
if (tEnd > windowStart && tStart < windowEnd) {
|
||||||
|
occupied.push({
|
||||||
|
start: Math.max(tStart, windowStart),
|
||||||
|
end: Math.min(tEnd, windowEnd),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort occupied by start time and merge overlapping intervals
|
||||||
|
occupied.sort((a, b) => a.start - b.start);
|
||||||
|
const merged: Array<{ start: number; end: number }> = [];
|
||||||
|
for (const interval of occupied) {
|
||||||
|
if (merged.length > 0 && interval.start <= merged[merged.length - 1].end) {
|
||||||
|
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, interval.end);
|
||||||
|
} else {
|
||||||
|
merged.push({ ...interval });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute free slots between merged occupied intervals
|
||||||
|
const freeSlots: FreeSlot[] = [];
|
||||||
|
let cursor = windowStart;
|
||||||
|
for (const interval of merged) {
|
||||||
|
if (interval.start > cursor) {
|
||||||
|
const durationMinutes = (interval.start - cursor) / 60_000;
|
||||||
|
if (durationMinutes >= parsed.data.minSlotMinutes) {
|
||||||
|
freeSlots.push({
|
||||||
|
start: new Date(cursor).toISOString(),
|
||||||
|
end: new Date(interval.start).toISOString(),
|
||||||
|
durationMinutes: Math.round(durationMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor = Math.max(cursor, interval.end);
|
||||||
|
}
|
||||||
|
// Trailing free slot after last occupied interval
|
||||||
|
if (cursor < windowEnd) {
|
||||||
|
const durationMinutes = (windowEnd - cursor) / 60_000;
|
||||||
|
if (durationMinutes >= parsed.data.minSlotMinutes) {
|
||||||
|
freeSlots.push({
|
||||||
|
start: new Date(cursor).toISOString(),
|
||||||
|
end: new Date(windowEnd).toISOString(),
|
||||||
|
durationMinutes: Math.round(durationMinutes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { slots: freeSlots, totalFreeMinutes: freeSlots.reduce((s, f) => s + f.durationMinutes, 0) };
|
||||||
|
});
|
||||||
|
|
||||||
// Batch upsert (initial sync / offline queue flush)
|
// Batch upsert (initial sync / offline queue flush)
|
||||||
app.post('/timers/batch', async req => {
|
app.post('/timers/batch', async req => {
|
||||||
const auth = await extractAuth(req);
|
const auth = await extractAuth(req);
|
||||||
|
|||||||
@ -158,6 +158,31 @@ export const BatchUpsertSchema = z.object({
|
|||||||
timers: z.array(CreateTimerSchema).min(1).max(100),
|
timers: z.array(CreateTimerSchema).min(1).max(100),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Reschedule schema (Phase A.1) ──
|
||||||
|
|
||||||
|
export const RescheduleTimerSchema = z.object({
|
||||||
|
deltaSeconds: z.number().int().optional().describe('Shift timer by N seconds (positive = later, negative = earlier)'),
|
||||||
|
targetTime: z.string().datetime().optional().describe('Set new absolute target time (ISO 8601)'),
|
||||||
|
syncVersion: z.number().int().min(1),
|
||||||
|
}).refine(
|
||||||
|
d => (d.deltaSeconds !== undefined) !== (d.targetTime !== undefined),
|
||||||
|
{ message: 'Provide exactly one of deltaSeconds or targetTime' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Availability query schema (Phase A.1) ──
|
||||||
|
|
||||||
|
export const AvailabilityQuerySchema = z.object({
|
||||||
|
start: z.string().datetime().describe('Start of time window (ISO 8601)'),
|
||||||
|
end: z.string().datetime().describe('End of time window (ISO 8601)'),
|
||||||
|
minSlotMinutes: z.coerce.number().int().min(1).max(480).default(15).describe('Minimum free slot duration in minutes'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface FreeSlot {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Inferred types ──
|
// ── Inferred types ──
|
||||||
|
|
||||||
export type CreateTimerInput = z.infer<typeof CreateTimerSchema>;
|
export type CreateTimerInput = z.infer<typeof CreateTimerSchema>;
|
||||||
@ -165,6 +190,8 @@ export type UpdateTimerInput = z.infer<typeof UpdateTimerSchema>;
|
|||||||
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
|
export type TimerQuery = z.infer<typeof TimerQuerySchema>;
|
||||||
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
|
export type TimerSyncQuery = z.infer<typeof TimerSyncQuerySchema>;
|
||||||
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
|
export type BatchUpsertInput = z.infer<typeof BatchUpsertSchema>;
|
||||||
|
export type RescheduleTimerInput = z.infer<typeof RescheduleTimerSchema>;
|
||||||
|
export type AvailabilityQuery = z.infer<typeof AvailabilityQuerySchema>;
|
||||||
|
|
||||||
// ── Batch result ──
|
// ── Batch result ──
|
||||||
|
|
||||||
|
|||||||
@ -92,29 +92,29 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`.
|
|||||||
|
|
||||||
### 0.1 — Kill Switch Client (Web)
|
### 0.1 — Kill Switch Client (Web)
|
||||||
|
|
||||||
- [ ] `web/src/lib/kill-switch.ts` — integrate `@bytelyst/kill-switch-client` (commit: )
|
- [x] `web/src/lib/kill-switch.ts` — integrate `@bytelyst/kill-switch-client` (commit: f3e14e2)
|
||||||
- Same pattern as NomGap/NoteLett: `createKillSwitchClient({ baseUrl, productId, platform })`
|
- Same pattern as NomGap/NoteLett: `createKillSwitchClient({ baseUrl, productId, platform })`
|
||||||
- Check on app init → if killed, show maintenance banner and disable timer creation
|
- Check on app init → if killed, show maintenance banner and disable timer creation
|
||||||
- Add `pnpm add @bytelyst/kill-switch-client` to web/package.json
|
- Add `pnpm add @bytelyst/kill-switch-client` to web/package.json
|
||||||
- [ ] Wire into `web/src/app/providers.tsx` — init on mount (commit: )
|
- [x] Wire into `web/src/app/providers.tsx` — init on mount (commit: f3e14e2)
|
||||||
|
|
||||||
### 0.2 — Feedback Client (Web)
|
### 0.2 — Feedback Client (Web)
|
||||||
|
|
||||||
- [ ] `web/src/lib/feedback.ts` — integrate `@bytelyst/feedback-client` (commit: )
|
- [x] `web/src/lib/feedback.ts` — integrate `@bytelyst/feedback-client` (commit: f3e14e2)
|
||||||
- `createFeedbackClient({ baseUrl, productId, getAccessToken })`
|
- `createFeedbackClient({ baseUrl, productId, getAccessToken })`
|
||||||
- [ ] Add feedback button to settings page (or floating FAB) (commit: )
|
- [ ] Add feedback button to settings page (or floating FAB) (commit: )
|
||||||
- Use `@bytelyst/ui` FeedbackButton component if available, else minimal custom
|
- TODO(AGENTIC-3): Wire feedback button into settings page UI
|
||||||
|
|
||||||
### 0.3 — Accessibility Package (Web)
|
### 0.3 — Accessibility Package (Web)
|
||||||
|
|
||||||
- [ ] Integrate `@bytelyst/accessibility` helpers where applicable (commit: )
|
- [x] Add `@bytelyst/accessibility` to web/package.json (commit: f3e14e2)
|
||||||
- Focus trap for modals (CreateTimerModal, AlarmOverlay)
|
- [ ] Integrate helpers into existing modals (commit: )
|
||||||
- Screen reader announcements for timer state changes
|
- TODO(AGENTIC-4): Apply focus trap + screen reader announcements to CreateTimerModal, AlarmOverlay
|
||||||
- Ensure all `--cm-*` color tokens meet WCAG AA contrast
|
- Ensure all `--cm-*` color tokens meet WCAG AA contrast
|
||||||
|
|
||||||
### 0.4 — Feature Flags for New Features (Backend)
|
### 0.4 — Feature Flags for New Features (Backend)
|
||||||
|
|
||||||
- [ ] Add feature flags for all new agentic features to `backend/src/lib/feature-flags.ts` (commit: )
|
- [x] Add feature flags for all new agentic features to `backend/src/lib/feature-flags.ts` (commit: f3e14e2)
|
||||||
```
|
```
|
||||||
'mcp.enabled': false,
|
'mcp.enabled': false,
|
||||||
'planner.enabled': false,
|
'planner.enabled': false,
|
||||||
@ -124,11 +124,11 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`.
|
|||||||
'ai_context_messages.enabled': false,
|
'ai_context_messages.enabled': false,
|
||||||
'webhooks.zapier': false,
|
'webhooks.zapier': false,
|
||||||
```
|
```
|
||||||
- [ ] Gate all new backend routes behind their respective flags (commit: )
|
- [ ] Gate all new backend routes behind their respective flags (ongoing — applied per-route as built)
|
||||||
|
|
||||||
### 0.5 — Telemetry Events for New Features
|
### 0.5 — Telemetry Events for New Features
|
||||||
|
|
||||||
- [ ] Define telemetry event names for all new features (commit: )
|
- [x] Define telemetry event names for all new features (commit: f3e14e2)
|
||||||
- `mcp.tool_executed`, `mcp.tool_failed`
|
- `mcp.tool_executed`, `mcp.tool_failed`
|
||||||
- `planner.day_planned`, `planner.plan_applied`, `planner.plan_rejected`
|
- `planner.day_planned`, `planner.plan_applied`, `planner.plan_rejected`
|
||||||
- `agent_inbox.action_approved`, `agent_inbox.action_rejected`, `agent_inbox.batch_approved`
|
- `agent_inbox.action_approved`, `agent_inbox.action_rejected`, `agent_inbox.batch_approved`
|
||||||
@ -140,12 +140,12 @@ These proxy to `chronomind-backend` (port 4011) via `chronomind-client.ts`.
|
|||||||
|
|
||||||
### Phase 0 Exit Criteria
|
### Phase 0 Exit Criteria
|
||||||
|
|
||||||
- [ ] Kill switch client initialized on web app startup
|
- [x] Kill switch client initialized on web app startup (f3e14e2)
|
||||||
- [ ] Feedback client wired with at least one entry point
|
- [x] Feedback client created (f3e14e2) — UI entry point deferred to TODO(AGENTIC-3)
|
||||||
- [ ] Accessibility helpers applied to modals
|
- [x] Accessibility package added (f3e14e2) — modal integration deferred to TODO(AGENTIC-4)
|
||||||
- [ ] 7 new feature flags registered (all default `false`)
|
- [x] 7 new feature flags registered (all default `false`) (f3e14e2)
|
||||||
- [ ] Telemetry event names defined
|
- [x] Telemetry event names defined (16 events, backend + web) (f3e14e2)
|
||||||
- [ ] All existing tests still pass
|
- [x] All existing tests still pass (182 backend + 394 web)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user