source-calendar-patch.mjs
154 lines 5.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * PATCH semantics for SourceCalendar display/agent toggles (Calendar Events v0).
3 *
4 * @see docs/CALENDAR-EVENTS-V0-SPEC.md — PATCH /api/v1/calendar/source-calendars/:id
5 */
6
7 import {
8 loadCalendarStore,
9 saveCalendarStore,
10 sourceCalendarForClient,
11 } from './event-store.mjs';
12 import {
13 AGENT_CONTEXT_TIERS,
14 } from './source-calendar-defaults.mjs';
15 import {
16 isAgentTierWithinPolicyCap,
17 readCalendarAgentTierCap,
18 } from './calendar-policy.mjs';
19
20 /** @typedef {import('./source-calendar-defaults.mjs').AgentContextTier} AgentContextTier */
21
22 /** @type {readonly ('personal'|'work'|'school'|'other')[]} */
23 export const SOURCE_CALENDAR_USER_GROUPS = Object.freeze(['personal', 'work', 'school', 'other']);
24
25 /**
26 * @typedef {Object} SourceCalendarPatchInput
27 * @property {boolean} [enabled_for_display]
28 * @property {boolean} [enabled_for_agents]
29 * @property {AgentContextTier} [agent_context_tier_max]
30 * @property {'personal'|'work'|'school'|'other'|null} [user_group]
31 */
32
33 /**
34 * @typedef {Object} SourceCalendarPatchResult
35 * @property {ReturnType<typeof sourceCalendarForClient>} source_calendar
36 * @property {AgentContextTier} policy_agent_context_tier_max_cap
37 */
38
39 /**
40 * Parse and validate a PATCH body. Throws RangeError/TypeError on invalid input.
41 *
42 * @param {unknown} body
43 * @returns {SourceCalendarPatchInput}
44 */
45 export function parseSourceCalendarPatchBody(body) {
46 if (!body || typeof body !== 'object' || Array.isArray(body)) {
47 throw new TypeError('Request body must be a JSON object');
48 }
49
50 /** @type {SourceCalendarPatchInput} */
51 const patch = {};
52 const record = /** @type {Record<string, unknown>} */ (body);
53
54 if ('enabled_for_display' in record) {
55 if (typeof record.enabled_for_display !== 'boolean') {
56 throw new TypeError('enabled_for_display must be a boolean');
57 }
58 patch.enabled_for_display = record.enabled_for_display;
59 }
60
61 if ('enabled_for_agents' in record) {
62 if (typeof record.enabled_for_agents !== 'boolean') {
63 throw new TypeError('enabled_for_agents must be a boolean');
64 }
65 patch.enabled_for_agents = record.enabled_for_agents;
66 }
67
68 if ('agent_context_tier_max' in record) {
69 const tier = record.agent_context_tier_max;
70 if (!Number.isInteger(tier) || !AGENT_CONTEXT_TIERS.includes(/** @type {AgentContextTier} */ (tier))) {
71 throw new RangeError('agent_context_tier_max must be an integer 0–4');
72 }
73 patch.agent_context_tier_max = /** @type {AgentContextTier} */ (tier);
74 }
75
76 if ('user_group' in record) {
77 const group = record.user_group;
78 if (group === null) {
79 patch.user_group = null;
80 } else if (typeof group === 'string' && SOURCE_CALENDAR_USER_GROUPS.includes(/** @type {*} */ (group))) {
81 patch.user_group = /** @type {'personal'|'work'|'school'|'other'} */ (group);
82 } else {
83 throw new RangeError('user_group must be personal, work, school, other, or null');
84 }
85 }
86
87 if (Object.keys(patch).length === 0) {
88 throw new TypeError('At least one patch field is required');
89 }
90
91 return patch;
92 }
93
94 /**
95 * Apply toggle patch to one source calendar in a vault store.
96 *
97 * @param {string} dataDir
98 * @param {string} vaultId
99 * @param {string} sourceCalendarId
100 * @param {SourceCalendarPatchInput} patch
101 * @returns {SourceCalendarPatchResult}
102 */
103 export function patchSourceCalendar(dataDir, vaultId, sourceCalendarId, patch) {
104 const id = String(sourceCalendarId ?? '').trim();
105 if (!id) {
106 throw new TypeError('source_calendar_id is required');
107 }
108
109 const policyCap = readCalendarAgentTierCap(dataDir);
110 const store = loadCalendarStore(dataDir);
111 if (!store.vaults[vaultId]) {
112 store.vaults[vaultId] = { source_calendars: [], events: [] };
113 }
114 const vaultStore = store.vaults[vaultId];
115 const calendar = vaultStore.source_calendars.find((c) => c.source_calendar_id === id);
116 if (!calendar) {
117 throw new Error(`Source calendar not found: ${id}`);
118 }
119
120 if (patch.enabled_for_display !== undefined) {
121 calendar.enabled_for_display = patch.enabled_for_display;
122 }
123 if (patch.enabled_for_agents !== undefined) {
124 calendar.enabled_for_agents = patch.enabled_for_agents;
125 }
126 if (patch.user_group !== undefined) {
127 calendar.user_group = patch.user_group;
128 }
129 if (patch.agent_context_tier_max !== undefined) {
130 if (!isAgentTierWithinPolicyCap(patch.agent_context_tier_max, policyCap)) {
131 const err = new RangeError(
132 `agent_context_tier_max ${patch.agent_context_tier_max} exceeds policy cap ${policyCap}`,
133 );
134 err.code = 'POLICY_CAP_EXCEEDED';
135 throw err;
136 }
137 calendar.agent_context_tier_max = patch.agent_context_tier_max;
138 }
139
140 const effectiveTier = calendar.agent_context_tier_max;
141 if (calendar.enabled_for_agents && !isAgentTierWithinPolicyCap(effectiveTier, policyCap)) {
142 const err = new RangeError(
143 `enabled_for_agents requires agent_context_tier_max ≤ policy cap ${policyCap}`,
144 );
145 err.code = 'POLICY_CAP_EXCEEDED';
146 throw err;
147 }
148
149 saveCalendarStore(dataDir, store);
150 return {
151 source_calendar: sourceCalendarForClient(calendar),
152 policy_agent_context_tier_max_cap: policyCap,
153 };
154 }
File History 1 commit
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago