forge-openclaw-plugin 0.2.15 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +39 -4
  2. package/dist/assets/{board-C_m78kvK.js → board-8L3uX7_O.js} +2 -2
  3. package/dist/assets/{board-C_m78kvK.js.map → board-8L3uX7_O.js.map} +1 -1
  4. package/dist/assets/index-Cj1IBH_w.js +36 -0
  5. package/dist/assets/index-Cj1IBH_w.js.map +1 -0
  6. package/dist/assets/index-DQT6EbuS.css +1 -0
  7. package/dist/assets/{motion-CpZvZumD.js → motion-1GAqqi8M.js} +2 -2
  8. package/dist/assets/{motion-CpZvZumD.js.map → motion-1GAqqi8M.js.map} +1 -1
  9. package/dist/assets/{table-DtyXTw03.js → table-DBGlgRjk.js} +2 -2
  10. package/dist/assets/{table-DtyXTw03.js.map → table-DBGlgRjk.js.map} +1 -1
  11. package/dist/assets/{ui-BXbpiKyS.js → ui-iTluWjC4.js} +2 -2
  12. package/dist/assets/{ui-BXbpiKyS.js.map → ui-iTluWjC4.js.map} +1 -1
  13. package/dist/assets/{vendor-QBH6qVEe.js → vendor-BvM2F9Dp.js} +151 -81
  14. package/dist/assets/vendor-BvM2F9Dp.js.map +1 -0
  15. package/dist/assets/{viz-w-IMeueL.js → viz-CNeunkfu.js} +2 -2
  16. package/dist/assets/{viz-w-IMeueL.js.map → viz-CNeunkfu.js.map} +1 -1
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/local-runtime.js +142 -9
  19. package/dist/openclaw/parity.js +1 -0
  20. package/dist/openclaw/plugin-entry-shared.js +7 -1
  21. package/dist/openclaw/routes.js +7 -0
  22. package/dist/openclaw/tools.js +198 -16
  23. package/dist/server/app.js +2615 -251
  24. package/dist/server/managers/platform/secrets-manager.js +44 -1
  25. package/dist/server/managers/runtime.js +3 -1
  26. package/dist/server/openapi.js +2212 -170
  27. package/dist/server/repositories/calendar.js +1101 -0
  28. package/dist/server/repositories/deleted-entities.js +10 -2
  29. package/dist/server/repositories/habits.js +358 -0
  30. package/dist/server/repositories/notes.js +161 -28
  31. package/dist/server/repositories/projects.js +45 -13
  32. package/dist/server/repositories/rewards.js +176 -6
  33. package/dist/server/repositories/settings.js +47 -5
  34. package/dist/server/repositories/task-runs.js +46 -10
  35. package/dist/server/repositories/tasks.js +25 -9
  36. package/dist/server/repositories/weekly-reviews.js +109 -0
  37. package/dist/server/repositories/work-adjustments.js +105 -0
  38. package/dist/server/services/calendar-runtime.js +1301 -0
  39. package/dist/server/services/context.js +16 -6
  40. package/dist/server/services/dashboard.js +6 -3
  41. package/dist/server/services/entity-crud.js +116 -3
  42. package/dist/server/services/gamification.js +66 -18
  43. package/dist/server/services/insights.js +2 -1
  44. package/dist/server/services/projects.js +32 -8
  45. package/dist/server/services/reviews.js +17 -2
  46. package/dist/server/services/work-time.js +27 -0
  47. package/dist/server/types.js +1069 -45
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -1
  50. package/server/migrations/003_habits.sql +30 -0
  51. package/server/migrations/004_habit_links.sql +8 -0
  52. package/server/migrations/005_habit_psyche_links.sql +24 -0
  53. package/server/migrations/006_work_adjustments.sql +14 -0
  54. package/server/migrations/007_weekly_review_closures.sql +17 -0
  55. package/server/migrations/008_calendar_execution.sql +147 -0
  56. package/server/migrations/009_true_calendar_events.sql +195 -0
  57. package/server/migrations/010_calendar_selection_state.sql +6 -0
  58. package/server/migrations/011_calendar_timezone_backfill.sql +11 -0
  59. package/server/migrations/012_work_block_ranges.sql +7 -0
  60. package/server/migrations/013_microsoft_local_auth_settings.sql +8 -0
  61. package/server/migrations/014_note_tags_and_ephemeral.sql +8 -0
  62. package/skills/forge-openclaw/SKILL.md +130 -10
  63. package/skills/forge-openclaw/cron_jobs.md +395 -0
  64. package/dist/assets/index-BWtLtXwb.js +0 -36
  65. package/dist/assets/index-BWtLtXwb.js.map +0 -1
  66. package/dist/assets/index-Dp5GXY_z.css +0 -1
  67. package/dist/assets/vendor-QBH6qVEe.js.map +0 -1
@@ -0,0 +1,1101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDatabase, runInTransaction } from "../db.js";
3
+ import { recordActivityEvent } from "./activity-events.js";
4
+ import { getProjectById } from "./projects.js";
5
+ import { getTaskById } from "./tasks.js";
6
+ import { calendarConnectionSchema, calendarContextConflictSchema, calendarEventSchema, calendarEventLinkSchema, calendarEventSourceSchema, calendarOverviewPayloadSchema, calendarSchema, calendarSchedulingRulesSchema, taskTimeboxSchema, workBlockInstanceSchema, workBlockTemplateSchema } from "../types.js";
7
+ const DEFAULT_SCHEDULING_RULES = {
8
+ allowWorkBlockKinds: [],
9
+ blockWorkBlockKinds: [],
10
+ allowCalendarIds: [],
11
+ blockCalendarIds: [],
12
+ allowEventTypes: [],
13
+ blockEventTypes: [],
14
+ allowEventKeywords: [],
15
+ blockEventKeywords: [],
16
+ allowAvailability: [],
17
+ blockAvailability: []
18
+ };
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+ function dateOnly(value) {
23
+ return value.toISOString().slice(0, 10);
24
+ }
25
+ function dateOnlyToUtcDate(value) {
26
+ const [yearText, monthText, dayText] = value.split("-");
27
+ return new Date(Date.UTC(Number(yearText), Number(monthText) - 1, Number(dayText)));
28
+ }
29
+ function normalizeTimezone(value) {
30
+ const normalized = value?.trim();
31
+ return normalized && normalized.length > 0 ? normalized : "UTC";
32
+ }
33
+ function mapConnection(row) {
34
+ const base = calendarConnectionSchema.parse({
35
+ id: row.id,
36
+ provider: row.provider,
37
+ label: row.label,
38
+ accountLabel: row.account_label,
39
+ status: row.status,
40
+ config: JSON.parse(row.config_json || "{}"),
41
+ forgeCalendarId: row.forge_calendar_id,
42
+ lastSyncedAt: row.last_synced_at,
43
+ lastSyncError: row.last_sync_error,
44
+ createdAt: row.created_at,
45
+ updatedAt: row.updated_at
46
+ });
47
+ return {
48
+ ...base,
49
+ credentialsSecretId: row.credentials_secret_id
50
+ };
51
+ }
52
+ function mapCalendar(row) {
53
+ return calendarSchema.parse({
54
+ id: row.id,
55
+ connectionId: row.connection_id,
56
+ remoteId: row.remote_id,
57
+ title: row.title,
58
+ description: row.description,
59
+ color: row.color,
60
+ timezone: normalizeTimezone(row.timezone),
61
+ isPrimary: Boolean(row.is_primary),
62
+ canWrite: Boolean(row.can_write),
63
+ selectedForSync: Boolean(row.selected_for_sync),
64
+ forgeManaged: Boolean(row.forge_managed),
65
+ lastSyncedAt: row.last_synced_at,
66
+ createdAt: row.created_at,
67
+ updatedAt: row.updated_at
68
+ });
69
+ }
70
+ function mapEventSource(row) {
71
+ return calendarEventSourceSchema.parse({
72
+ id: row.id,
73
+ provider: row.provider,
74
+ connectionId: row.connection_id,
75
+ calendarId: row.calendar_id,
76
+ remoteCalendarId: row.remote_calendar_id,
77
+ remoteEventId: row.remote_event_id,
78
+ remoteUid: row.remote_uid,
79
+ recurrenceInstanceId: row.recurrence_instance_id,
80
+ isMasterRecurring: Boolean(row.is_master_recurring),
81
+ remoteHref: row.remote_href,
82
+ remoteEtag: row.remote_etag,
83
+ syncState: row.sync_state,
84
+ lastSyncedAt: row.last_synced_at,
85
+ createdAt: row.created_at,
86
+ updatedAt: row.updated_at
87
+ });
88
+ }
89
+ function mapEventLink(row) {
90
+ return calendarEventLinkSchema.parse({
91
+ id: row.id,
92
+ entityType: row.entity_type,
93
+ entityId: row.entity_id,
94
+ relationshipType: row.relationship_type,
95
+ createdAt: row.created_at,
96
+ updatedAt: row.updated_at
97
+ });
98
+ }
99
+ function listEventSourcesForEvent(eventId) {
100
+ const rows = getDatabase()
101
+ .prepare(`SELECT id, forge_event_id, provider, connection_id, calendar_id, remote_calendar_id, remote_event_id, remote_uid,
102
+ recurrence_instance_id, is_master_recurring, remote_href, remote_etag, sync_state, raw_payload_json,
103
+ last_synced_at, created_at, updated_at
104
+ FROM forge_event_sources
105
+ WHERE forge_event_id = ?
106
+ ORDER BY updated_at DESC, created_at DESC`)
107
+ .all(eventId);
108
+ return rows.map(mapEventSource);
109
+ }
110
+ function listEventLinksForEvent(eventId) {
111
+ const rows = getDatabase()
112
+ .prepare(`SELECT id, forge_event_id, entity_type, entity_id, relationship_type, created_at, updated_at
113
+ FROM forge_event_links
114
+ WHERE forge_event_id = ?
115
+ ORDER BY updated_at DESC, created_at DESC`)
116
+ .all(eventId);
117
+ return rows.map(mapEventLink);
118
+ }
119
+ function mapEvent(row) {
120
+ const sourceMappings = listEventSourcesForEvent(row.id);
121
+ const primarySource = sourceMappings[0] ?? null;
122
+ return calendarEventSchema.parse({
123
+ id: row.id,
124
+ connectionId: row.preferred_connection_id ?? primarySource?.connectionId ?? null,
125
+ calendarId: row.preferred_calendar_id ?? primarySource?.calendarId ?? null,
126
+ remoteId: primarySource?.remoteEventId ?? null,
127
+ ownership: row.ownership,
128
+ originType: row.origin_type,
129
+ status: row.status,
130
+ title: row.title,
131
+ description: row.description,
132
+ location: row.location,
133
+ startAt: row.start_at,
134
+ endAt: row.end_at,
135
+ timezone: normalizeTimezone(row.timezone),
136
+ isAllDay: Boolean(row.is_all_day),
137
+ availability: row.availability,
138
+ eventType: row.event_type,
139
+ categories: JSON.parse(row.categories_json || "[]"),
140
+ sourceMappings,
141
+ links: listEventLinksForEvent(row.id),
142
+ remoteUpdatedAt: primarySource?.lastSyncedAt ?? null,
143
+ deletedAt: row.deleted_at,
144
+ createdAt: row.created_at,
145
+ updatedAt: row.updated_at
146
+ });
147
+ }
148
+ function mapWorkBlockTemplate(row) {
149
+ return workBlockTemplateSchema.parse({
150
+ id: row.id,
151
+ title: row.title,
152
+ kind: row.kind,
153
+ color: row.color,
154
+ timezone: normalizeTimezone(row.timezone),
155
+ weekDays: JSON.parse(row.weekdays_json || "[]"),
156
+ startMinute: row.start_minute,
157
+ endMinute: row.end_minute,
158
+ startsOn: row.starts_on,
159
+ endsOn: row.ends_on,
160
+ blockingState: row.blocking_state,
161
+ createdAt: row.created_at,
162
+ updatedAt: row.updated_at
163
+ });
164
+ }
165
+ function mapTimebox(row) {
166
+ return taskTimeboxSchema.parse({
167
+ id: row.id,
168
+ taskId: row.task_id,
169
+ projectId: row.project_id,
170
+ connectionId: row.connection_id,
171
+ calendarId: row.calendar_id,
172
+ remoteEventId: row.remote_event_id,
173
+ linkedTaskRunId: row.linked_task_run_id,
174
+ status: row.status,
175
+ source: row.source,
176
+ title: row.title,
177
+ startsAt: row.starts_at,
178
+ endsAt: row.ends_at,
179
+ overrideReason: row.override_reason,
180
+ createdAt: row.created_at,
181
+ updatedAt: row.updated_at
182
+ });
183
+ }
184
+ function addMinutes(date, minutes) {
185
+ return new Date(date.getTime() + minutes * 60 * 1000);
186
+ }
187
+ function normalizeRules(rules) {
188
+ return calendarSchedulingRulesSchema.parse(rules ?? DEFAULT_SCHEDULING_RULES);
189
+ }
190
+ export function storeEncryptedSecret(secretId, cipherText, description = "") {
191
+ const now = nowIso();
192
+ getDatabase()
193
+ .prepare(`INSERT INTO stored_secrets (id, cipher_text, description, created_at, updated_at)
194
+ VALUES (?, ?, ?, ?, ?)
195
+ ON CONFLICT(id) DO UPDATE SET cipher_text = excluded.cipher_text, description = excluded.description, updated_at = excluded.updated_at`)
196
+ .run(secretId, cipherText, description, now, now);
197
+ }
198
+ export function readEncryptedSecret(secretId) {
199
+ const row = getDatabase()
200
+ .prepare(`SELECT id, cipher_text FROM stored_secrets WHERE id = ?`)
201
+ .get(secretId);
202
+ return row?.cipher_text;
203
+ }
204
+ export function deleteEncryptedSecret(secretId) {
205
+ getDatabase().prepare(`DELETE FROM stored_secrets WHERE id = ?`).run(secretId);
206
+ }
207
+ export function listCalendarConnections() {
208
+ const rows = getDatabase()
209
+ .prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
210
+ last_synced_at, last_sync_error, created_at, updated_at
211
+ FROM calendar_connections
212
+ ORDER BY created_at DESC`)
213
+ .all();
214
+ return rows.map(mapConnection);
215
+ }
216
+ export function getCalendarConnectionById(connectionId) {
217
+ const row = getDatabase()
218
+ .prepare(`SELECT id, provider, label, account_label, status, config_json, credentials_secret_id, forge_calendar_id,
219
+ last_synced_at, last_sync_error, created_at, updated_at
220
+ FROM calendar_connections
221
+ WHERE id = ?`)
222
+ .get(connectionId);
223
+ return row ? mapConnection(row) : undefined;
224
+ }
225
+ export function createCalendarConnectionRecord(input) {
226
+ const now = nowIso();
227
+ const id = `calconn_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
228
+ getDatabase()
229
+ .prepare(`INSERT INTO calendar_connections (
230
+ id, provider, label, account_label, status, config_json, credentials_secret_id, created_at, updated_at
231
+ )
232
+ VALUES (?, ?, ?, ?, 'connected', ?, ?, ?, ?)`)
233
+ .run(id, input.provider, input.label, input.accountLabel ?? "", JSON.stringify(input.config), input.credentialsSecretId, now, now);
234
+ return getCalendarConnectionById(id);
235
+ }
236
+ export function updateCalendarConnectionRecord(connectionId, patch) {
237
+ const current = getCalendarConnectionById(connectionId);
238
+ if (!current) {
239
+ return undefined;
240
+ }
241
+ const next = {
242
+ label: patch.label ?? current.label,
243
+ accountLabel: patch.accountLabel ?? current.accountLabel,
244
+ status: patch.status ?? current.status,
245
+ config: patch.config ?? current.config,
246
+ forgeCalendarId: patch.forgeCalendarId === undefined ? current.forgeCalendarId : patch.forgeCalendarId,
247
+ lastSyncedAt: patch.lastSyncedAt === undefined ? current.lastSyncedAt : patch.lastSyncedAt,
248
+ lastSyncError: patch.lastSyncError === undefined ? current.lastSyncError : patch.lastSyncError,
249
+ updatedAt: nowIso()
250
+ };
251
+ getDatabase()
252
+ .prepare(`UPDATE calendar_connections
253
+ SET label = ?, account_label = ?, status = ?, config_json = ?, forge_calendar_id = ?, last_synced_at = ?, last_sync_error = ?, updated_at = ?
254
+ WHERE id = ?`)
255
+ .run(next.label, next.accountLabel, next.status, JSON.stringify(next.config), next.forgeCalendarId, next.lastSyncedAt, next.lastSyncError, next.updatedAt, connectionId);
256
+ return getCalendarConnectionById(connectionId);
257
+ }
258
+ export function deleteCalendarConnectionRecord(connectionId) {
259
+ const current = getCalendarConnectionById(connectionId);
260
+ if (!current) {
261
+ return undefined;
262
+ }
263
+ getDatabase()
264
+ .prepare(`UPDATE calendar_connections
265
+ SET forge_calendar_id = NULL, updated_at = ?
266
+ WHERE id = ?`)
267
+ .run(nowIso(), connectionId);
268
+ getDatabase().prepare(`DELETE FROM calendar_connections WHERE id = ?`).run(connectionId);
269
+ return current;
270
+ }
271
+ export function deleteExternalEventsForConnection(connectionId) {
272
+ const rows = getDatabase()
273
+ .prepare(`SELECT id
274
+ FROM forge_events
275
+ WHERE ownership = 'external' AND preferred_connection_id = ?`)
276
+ .all(connectionId);
277
+ for (const row of rows) {
278
+ getDatabase().prepare(`DELETE FROM forge_events WHERE id = ?`).run(row.id);
279
+ }
280
+ return rows.map((row) => row.id);
281
+ }
282
+ export function detachConnectionFromForgeEvents(connectionId) {
283
+ const now = nowIso();
284
+ getDatabase()
285
+ .prepare(`UPDATE forge_events
286
+ SET preferred_connection_id = NULL,
287
+ preferred_calendar_id = NULL,
288
+ updated_at = ?
289
+ WHERE ownership = 'forge' AND preferred_connection_id = ?`)
290
+ .run(now, connectionId);
291
+ getDatabase()
292
+ .prepare(`DELETE FROM forge_event_sources
293
+ WHERE connection_id = ?`)
294
+ .run(connectionId);
295
+ }
296
+ export function listCalendars(connectionId, options = {}) {
297
+ const visibilityClause = options.includeUnselected
298
+ ? ""
299
+ : connectionId
300
+ ? "AND (selected_for_sync = 1 OR forge_managed = 1)"
301
+ : "WHERE (selected_for_sync = 1 OR forge_managed = 1)";
302
+ const rows = getDatabase()
303
+ .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
304
+ last_synced_at, created_at, updated_at
305
+ FROM calendar_calendars
306
+ ${connectionId ? `WHERE connection_id = ? ${visibilityClause}` : visibilityClause}
307
+ ORDER BY forge_managed DESC, title ASC`)
308
+ .all(...(connectionId ? [connectionId] : []));
309
+ return rows.map(mapCalendar);
310
+ }
311
+ export function getCalendarById(calendarId) {
312
+ const row = getDatabase()
313
+ .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
314
+ last_synced_at, created_at, updated_at
315
+ FROM calendar_calendars
316
+ WHERE id = ?`)
317
+ .get(calendarId);
318
+ return row ? mapCalendar(row) : undefined;
319
+ }
320
+ function getDefaultWritableCalendar() {
321
+ const row = getDatabase()
322
+ .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
323
+ last_synced_at, created_at, updated_at
324
+ FROM calendar_calendars
325
+ WHERE can_write = 1
326
+ AND (selected_for_sync = 1 OR forge_managed = 1)
327
+ ORDER BY forge_managed DESC, is_primary DESC, title ASC
328
+ LIMIT 1`)
329
+ .get();
330
+ return row ? mapCalendar(row) : undefined;
331
+ }
332
+ export function getCalendarByRemoteId(connectionId, remoteId) {
333
+ const row = getDatabase()
334
+ .prepare(`SELECT id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed,
335
+ last_synced_at, created_at, updated_at
336
+ FROM calendar_calendars
337
+ WHERE connection_id = ? AND remote_id = ?`)
338
+ .get(connectionId, remoteId);
339
+ return row ? mapCalendar(row) : undefined;
340
+ }
341
+ export function upsertCalendarRecord(connectionId, input) {
342
+ const existing = getCalendarByRemoteId(connectionId, input.remoteId);
343
+ const now = nowIso();
344
+ if (existing) {
345
+ getDatabase()
346
+ .prepare(`UPDATE calendar_calendars
347
+ SET title = ?, description = ?, color = ?, timezone = ?, is_primary = ?, can_write = ?, selected_for_sync = ?, forge_managed = ?, last_synced_at = ?, updated_at = ?
348
+ WHERE id = ?`)
349
+ .run(input.title, input.description ?? existing.description, input.color ?? existing.color, normalizeTimezone(input.timezone ?? existing.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, existing.id);
350
+ return getCalendarById(existing.id);
351
+ }
352
+ const id = `calendar_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
353
+ getDatabase()
354
+ .prepare(`INSERT INTO calendar_calendars (
355
+ id, connection_id, remote_id, title, description, color, timezone, is_primary, can_write, selected_for_sync, forge_managed, last_synced_at, created_at, updated_at
356
+ )
357
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
358
+ .run(id, connectionId, input.remoteId, input.title, input.description ?? "", input.color ?? "#7dd3fc", normalizeTimezone(input.timezone), input.isPrimary ? 1 : 0, input.canWrite === false ? 0 : 1, input.selectedForSync === false ? 0 : 1, input.forgeManaged ? 1 : 0, now, now, now);
359
+ return getCalendarById(id);
360
+ }
361
+ export function listCalendarEvents(query) {
362
+ const clauses = [
363
+ "deleted_at IS NULL",
364
+ `(ownership != 'external' OR preferred_calendar_id IS NULL OR EXISTS (
365
+ SELECT 1
366
+ FROM calendar_calendars visible_calendars
367
+ WHERE visible_calendars.id = forge_events.preferred_calendar_id
368
+ AND (visible_calendars.selected_for_sync = 1 OR visible_calendars.forge_managed = 1)
369
+ ))`
370
+ ];
371
+ const params = [];
372
+ if (query.connectionId) {
373
+ clauses.push("(preferred_connection_id = ? OR EXISTS (SELECT 1 FROM forge_event_sources src WHERE src.forge_event_id = forge_events.id AND src.connection_id = ?))");
374
+ params.push(query.connectionId);
375
+ params.push(query.connectionId);
376
+ }
377
+ if (query.calendarId) {
378
+ clauses.push("(preferred_calendar_id = ? OR EXISTS (SELECT 1 FROM forge_event_sources src WHERE src.forge_event_id = forge_events.id AND src.calendar_id = ?))");
379
+ params.push(query.calendarId);
380
+ params.push(query.calendarId);
381
+ }
382
+ clauses.push("end_at > ?");
383
+ params.push(query.from);
384
+ clauses.push("start_at < ?");
385
+ params.push(query.to);
386
+ const rows = getDatabase()
387
+ .prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
388
+ start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
389
+ FROM forge_events
390
+ WHERE ${clauses.join(" AND ")}
391
+ ORDER BY start_at ASC, title ASC`)
392
+ .all(...params);
393
+ return rows.map(mapEvent);
394
+ }
395
+ export function getCalendarEventById(eventId) {
396
+ const row = getDatabase()
397
+ .prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
398
+ start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
399
+ FROM forge_events
400
+ WHERE id = ?`)
401
+ .get(eventId);
402
+ return row ? mapEvent(row) : undefined;
403
+ }
404
+ export function getCalendarEventStorageRecord(eventId) {
405
+ return getDatabase()
406
+ .prepare(`SELECT id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
407
+ start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
408
+ FROM forge_events
409
+ WHERE id = ?`)
410
+ .get(eventId);
411
+ }
412
+ export function getCalendarEventByRemoteId(connectionId, calendarId, remoteId) {
413
+ const row = getDatabase()
414
+ .prepare(`SELECT forge_events.id, forge_events.preferred_connection_id, forge_events.preferred_calendar_id, forge_events.ownership,
415
+ forge_events.origin_type, forge_events.status, forge_events.title, forge_events.description, forge_events.location,
416
+ forge_events.start_at, forge_events.end_at, forge_events.timezone, forge_events.is_all_day, forge_events.availability,
417
+ forge_events.event_type, forge_events.categories_json, forge_events.deleted_at, forge_events.created_at, forge_events.updated_at
418
+ FROM forge_event_sources
419
+ INNER JOIN forge_events ON forge_events.id = forge_event_sources.forge_event_id
420
+ WHERE forge_event_sources.connection_id = ? AND forge_event_sources.calendar_id = ? AND forge_event_sources.remote_event_id = ?`)
421
+ .get(connectionId, calendarId, remoteId);
422
+ return row ? mapEvent(row) : undefined;
423
+ }
424
+ export function listCalendarEventSources(eventId) {
425
+ return listEventSourcesForEvent(eventId);
426
+ }
427
+ export function getPrimaryCalendarEventSource(eventId) {
428
+ return listEventSourcesForEvent(eventId)[0] ?? null;
429
+ }
430
+ function upsertEventSource(input) {
431
+ const now = nowIso();
432
+ const existing = getDatabase()
433
+ .prepare(`SELECT id
434
+ FROM forge_event_sources
435
+ WHERE provider = ? AND connection_id IS ? AND calendar_id IS ? AND remote_event_id = ?`)
436
+ .get(input.provider, input.connectionId ?? null, input.calendarId ?? null, input.remoteEventId);
437
+ if (existing) {
438
+ getDatabase()
439
+ .prepare(`UPDATE forge_event_sources
440
+ SET forge_event_id = ?, remote_calendar_id = ?, remote_uid = ?, recurrence_instance_id = ?, is_master_recurring = ?,
441
+ remote_href = ?, remote_etag = ?, sync_state = ?, raw_payload_json = ?, last_synced_at = ?, updated_at = ?
442
+ WHERE id = ?`)
443
+ .run(input.forgeEventId, input.remoteCalendarId ?? null, input.remoteUid ?? null, input.recurrenceInstanceId ?? null, input.isMasterRecurring ? 1 : 0, input.remoteHref ?? null, input.remoteEtag ?? null, input.syncState ?? "synced", input.rawPayloadJson ?? "{}", input.lastSyncedAt ?? null, now, existing.id);
444
+ return existing.id;
445
+ }
446
+ const id = `evsrc_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
447
+ getDatabase()
448
+ .prepare(`INSERT INTO forge_event_sources (
449
+ id, forge_event_id, provider, connection_id, calendar_id, remote_calendar_id, remote_event_id, remote_uid,
450
+ recurrence_instance_id, is_master_recurring, remote_href, remote_etag, sync_state, raw_payload_json, last_synced_at, created_at, updated_at
451
+ )
452
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
453
+ .run(id, input.forgeEventId, input.provider, input.connectionId ?? null, input.calendarId ?? null, input.remoteCalendarId ?? null, input.remoteEventId, input.remoteUid ?? null, input.recurrenceInstanceId ?? null, input.isMasterRecurring ? 1 : 0, input.remoteHref ?? null, input.remoteEtag ?? null, input.syncState ?? "synced", input.rawPayloadJson ?? "{}", input.lastSyncedAt ?? null, now, now);
454
+ return id;
455
+ }
456
+ export function registerCalendarEventSourceProjection(input) {
457
+ upsertEventSource(input);
458
+ return listEventSourcesForEvent(input.forgeEventId);
459
+ }
460
+ export function markCalendarEventSourcesSyncState(forgeEventId, syncState) {
461
+ const now = nowIso();
462
+ getDatabase()
463
+ .prepare(`UPDATE forge_event_sources
464
+ SET sync_state = ?, updated_at = ?
465
+ WHERE forge_event_id = ?`)
466
+ .run(syncState, now, forgeEventId);
467
+ }
468
+ function replaceEventLinks(forgeEventId, links) {
469
+ getDatabase().prepare(`DELETE FROM forge_event_links WHERE forge_event_id = ?`).run(forgeEventId);
470
+ const now = nowIso();
471
+ const insert = getDatabase().prepare(`INSERT INTO forge_event_links (id, forge_event_id, entity_type, entity_id, relationship_type, created_at, updated_at)
472
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
473
+ for (const link of links) {
474
+ insert.run(`evlink_${randomUUID().replaceAll("-", "").slice(0, 10)}`, forgeEventId, link.entityType, link.entityId, link.relationshipType ?? "context", now, now);
475
+ }
476
+ }
477
+ export function upsertCalendarEventRecord(connectionId, input) {
478
+ const calendar = getCalendarByRemoteId(connectionId, input.calendarRemoteId);
479
+ if (!calendar) {
480
+ throw new Error(`Calendar ${input.calendarRemoteId} is not registered for connection ${connectionId}`);
481
+ }
482
+ const connection = getCalendarConnectionById(connectionId);
483
+ if (!connection) {
484
+ throw new Error(`Calendar connection ${connectionId} is not registered`);
485
+ }
486
+ const existing = getCalendarEventByRemoteId(connectionId, calendar.id, input.remoteId);
487
+ const now = nowIso();
488
+ if (existing) {
489
+ getDatabase()
490
+ .prepare(`UPDATE forge_events
491
+ SET preferred_connection_id = ?, preferred_calendar_id = ?, ownership = ?, origin_type = ?, status = ?, title = ?, description = ?, location = ?,
492
+ start_at = ?, end_at = ?, timezone = ?, is_all_day = ?, availability = ?, event_type = ?, categories_json = ?, deleted_at = ?, updated_at = ?
493
+ WHERE id = ?`)
494
+ .run(connectionId, calendar.id, input.ownership ?? existing.ownership, connection.provider, input.status ?? existing.status, input.title, input.description ?? "", input.location ?? "", input.startAt, input.endAt, calendar.timezone, input.isAllDay ? 1 : 0, input.availability ?? existing.availability, input.eventType ?? "", JSON.stringify(input.categories ?? []), input.deletedAt ?? null, now, existing.id);
495
+ upsertEventSource({
496
+ forgeEventId: existing.id,
497
+ provider: connection.provider,
498
+ connectionId,
499
+ calendarId: calendar.id,
500
+ remoteCalendarId: calendar.remoteId,
501
+ remoteEventId: input.remoteId,
502
+ remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
503
+ recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
504
+ ? String(input.rawPayload.recurrenceid)
505
+ : null,
506
+ isMasterRecurring: Boolean(input.rawPayload?.rrule),
507
+ remoteHref: input.remoteHref ?? null,
508
+ remoteEtag: input.remoteEtag ?? null,
509
+ syncState: input.deletedAt ? "deleted" : "synced",
510
+ rawPayloadJson: JSON.stringify(input.rawPayload ?? {}),
511
+ lastSyncedAt: input.remoteUpdatedAt ?? now
512
+ });
513
+ return getCalendarEventById(existing.id);
514
+ }
515
+ const id = `calevent_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
516
+ getDatabase()
517
+ .prepare(`INSERT INTO forge_events (
518
+ id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
519
+ start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, deleted_at, created_at, updated_at
520
+ )
521
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
522
+ .run(id, connectionId, calendar.id, input.ownership ?? "external", connection.provider, input.status ?? "confirmed", input.title, input.description ?? "", input.location ?? "", input.startAt, input.endAt, calendar.timezone, input.isAllDay ? 1 : 0, input.availability ?? "busy", input.eventType ?? "", JSON.stringify(input.categories ?? []), input.deletedAt ?? null, now, now);
523
+ upsertEventSource({
524
+ forgeEventId: id,
525
+ provider: connection.provider,
526
+ connectionId,
527
+ calendarId: calendar.id,
528
+ remoteCalendarId: calendar.remoteId,
529
+ remoteEventId: input.remoteId,
530
+ remoteUid: typeof input.rawPayload?.uid === "string" ? String(input.rawPayload.uid) : null,
531
+ recurrenceInstanceId: typeof input.rawPayload?.recurrenceid === "string"
532
+ ? String(input.rawPayload.recurrenceid)
533
+ : null,
534
+ isMasterRecurring: Boolean(input.rawPayload?.rrule),
535
+ remoteHref: input.remoteHref ?? null,
536
+ remoteEtag: input.remoteEtag ?? null,
537
+ syncState: input.deletedAt ? "deleted" : "synced",
538
+ rawPayloadJson: JSON.stringify(input.rawPayload ?? {}),
539
+ lastSyncedAt: input.remoteUpdatedAt ?? now
540
+ });
541
+ return getCalendarEventById(id);
542
+ }
543
+ export function createCalendarEvent(input) {
544
+ const now = nowIso();
545
+ const id = `calevent_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
546
+ const preferredCalendar = input.preferredCalendarId === undefined
547
+ ? getDefaultWritableCalendar() ?? null
548
+ : input.preferredCalendarId
549
+ ? getCalendarById(input.preferredCalendarId)
550
+ : null;
551
+ getDatabase()
552
+ .prepare(`INSERT INTO forge_events (
553
+ id, preferred_connection_id, preferred_calendar_id, ownership, origin_type, status, title, description, location,
554
+ start_at, end_at, timezone, is_all_day, availability, event_type, categories_json, created_at, updated_at
555
+ )
556
+ VALUES (?, ?, ?, 'forge', 'native', 'confirmed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
557
+ .run(id, preferredCalendar?.connectionId ?? null, preferredCalendar?.id ?? null, input.title, input.description, input.location, input.startAt, input.endAt, normalizeTimezone(input.timezone), input.isAllDay ? 1 : 0, input.availability, input.eventType, JSON.stringify(input.categories), now, now);
558
+ replaceEventLinks(id, input.links);
559
+ return getCalendarEventById(id);
560
+ }
561
+ export function updateCalendarEvent(eventId, patch) {
562
+ const current = getCalendarEventById(eventId);
563
+ if (!current) {
564
+ return undefined;
565
+ }
566
+ const preferredCalendar = patch.preferredCalendarId === undefined
567
+ ? current.calendarId
568
+ ? getCalendarById(current.calendarId)
569
+ : null
570
+ : patch.preferredCalendarId
571
+ ? getCalendarById(patch.preferredCalendarId)
572
+ : null;
573
+ const next = {
574
+ preferredConnectionId: preferredCalendar?.connectionId ?? null,
575
+ preferredCalendarId: patch.preferredCalendarId === undefined
576
+ ? current.calendarId
577
+ : patch.preferredCalendarId,
578
+ title: patch.title ?? current.title,
579
+ description: patch.description ?? current.description,
580
+ location: patch.location ?? current.location,
581
+ startAt: patch.startAt ?? current.startAt,
582
+ endAt: patch.endAt ?? current.endAt,
583
+ timezone: normalizeTimezone(patch.timezone ?? current.timezone),
584
+ isAllDay: patch.isAllDay ?? current.isAllDay,
585
+ availability: patch.availability ?? current.availability,
586
+ eventType: patch.eventType ?? current.eventType,
587
+ categories: patch.categories ?? current.categories,
588
+ updatedAt: nowIso()
589
+ };
590
+ getDatabase()
591
+ .prepare(`UPDATE forge_events
592
+ SET preferred_connection_id = ?, preferred_calendar_id = ?, title = ?, description = ?, location = ?,
593
+ start_at = ?, end_at = ?, timezone = ?, is_all_day = ?, availability = ?, event_type = ?, categories_json = ?, updated_at = ?
594
+ WHERE id = ?`)
595
+ .run(next.preferredConnectionId, next.preferredCalendarId, next.title, next.description, next.location, next.startAt, next.endAt, next.timezone, next.isAllDay ? 1 : 0, next.availability, next.eventType, JSON.stringify(next.categories), next.updatedAt, eventId);
596
+ if (patch.links) {
597
+ replaceEventLinks(eventId, patch.links);
598
+ }
599
+ if (current.sourceMappings.length > 0) {
600
+ const nextSyncState = current.deletedAt !== null ? "deleted" : current.originType === "native" ? "pending_update" : "synced";
601
+ getDatabase()
602
+ .prepare(`UPDATE forge_event_sources
603
+ SET sync_state = ?, updated_at = ?
604
+ WHERE forge_event_id = ? AND sync_state != 'deleted'`)
605
+ .run(nextSyncState, next.updatedAt, eventId);
606
+ }
607
+ return getCalendarEventById(eventId);
608
+ }
609
+ export function deleteCalendarEvent(eventId) {
610
+ const current = getCalendarEventById(eventId);
611
+ if (!current) {
612
+ return undefined;
613
+ }
614
+ const deletedAt = nowIso();
615
+ getDatabase()
616
+ .prepare(`UPDATE forge_events
617
+ SET deleted_at = ?, updated_at = ?
618
+ WHERE id = ?`)
619
+ .run(deletedAt, deletedAt, eventId);
620
+ getDatabase()
621
+ .prepare(`UPDATE forge_event_sources
622
+ SET sync_state = CASE WHEN remote_event_id IS NOT NULL THEN 'pending_delete' ELSE sync_state END,
623
+ updated_at = ?
624
+ WHERE forge_event_id = ? AND sync_state != 'deleted'`)
625
+ .run(deletedAt, eventId);
626
+ return getCalendarEventById(eventId);
627
+ }
628
+ export function createWorkBlockTemplate(input) {
629
+ return runInTransaction(() => {
630
+ const now = nowIso();
631
+ const id = `wbtpl_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
632
+ getDatabase()
633
+ .prepare(`INSERT INTO work_block_templates (
634
+ id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
635
+ )
636
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
637
+ .run(id, input.title, input.kind, input.color, normalizeTimezone(input.timezone), JSON.stringify(input.weekDays), input.startMinute, input.endMinute, input.startsOn ?? null, input.endsOn ?? null, input.blockingState, now, now);
638
+ return getWorkBlockTemplateById(id);
639
+ });
640
+ }
641
+ export function listWorkBlockTemplates() {
642
+ const rows = getDatabase()
643
+ .prepare(`SELECT id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
644
+ FROM work_block_templates
645
+ ORDER BY COALESCE(starts_on, ''), start_minute ASC, title ASC`)
646
+ .all();
647
+ return rows.map(mapWorkBlockTemplate);
648
+ }
649
+ export function getWorkBlockTemplateById(templateId) {
650
+ const row = getDatabase()
651
+ .prepare(`SELECT id, title, kind, color, timezone, weekdays_json, start_minute, end_minute, starts_on, ends_on, blocking_state, created_at, updated_at
652
+ FROM work_block_templates
653
+ WHERE id = ?`)
654
+ .get(templateId);
655
+ return row ? mapWorkBlockTemplate(row) : undefined;
656
+ }
657
+ export function updateWorkBlockTemplate(templateId, patch) {
658
+ const current = getWorkBlockTemplateById(templateId);
659
+ if (!current) {
660
+ return undefined;
661
+ }
662
+ const next = {
663
+ title: patch.title ?? current.title,
664
+ kind: patch.kind ?? current.kind,
665
+ color: patch.color ?? current.color,
666
+ timezone: normalizeTimezone(patch.timezone ?? current.timezone),
667
+ weekDays: patch.weekDays ?? current.weekDays,
668
+ startMinute: patch.startMinute ?? current.startMinute,
669
+ endMinute: patch.endMinute ?? current.endMinute,
670
+ startsOn: patch.startsOn === undefined ? current.startsOn : patch.startsOn,
671
+ endsOn: patch.endsOn === undefined ? current.endsOn : patch.endsOn,
672
+ blockingState: patch.blockingState ?? current.blockingState,
673
+ updatedAt: nowIso()
674
+ };
675
+ getDatabase()
676
+ .prepare(`UPDATE work_block_templates
677
+ SET title = ?, kind = ?, color = ?, timezone = ?, weekdays_json = ?, start_minute = ?, end_minute = ?, starts_on = ?, ends_on = ?, blocking_state = ?, updated_at = ?
678
+ WHERE id = ?`)
679
+ .run(next.title, next.kind, next.color, next.timezone, JSON.stringify(next.weekDays), next.startMinute, next.endMinute, next.startsOn, next.endsOn, next.blockingState, next.updatedAt, templateId);
680
+ return getWorkBlockTemplateById(templateId);
681
+ }
682
+ export function deleteWorkBlockTemplate(templateId) {
683
+ const current = getWorkBlockTemplateById(templateId);
684
+ if (!current) {
685
+ return undefined;
686
+ }
687
+ getDatabase().prepare(`DELETE FROM work_block_templates WHERE id = ?`).run(templateId);
688
+ return current;
689
+ }
690
+ function deriveWorkBlockInstances(template, query) {
691
+ const queryStart = new Date(query.from);
692
+ const queryEnd = new Date(query.to);
693
+ const start = new Date(Date.UTC(queryStart.getUTCFullYear(), queryStart.getUTCMonth(), queryStart.getUTCDate()));
694
+ const end = new Date(Date.UTC(queryEnd.getUTCFullYear(), queryEnd.getUTCMonth(), queryEnd.getUTCDate()));
695
+ const templateStart = template.startsOn ? dateOnlyToUtcDate(template.startsOn) : null;
696
+ const templateEnd = template.endsOn ? dateOnlyToUtcDate(template.endsOn) : null;
697
+ const firstDay = templateStart && templateStart.getTime() > start.getTime() ? templateStart : start;
698
+ const lastDay = templateEnd && templateEnd.getTime() < end.getTime() ? templateEnd : end;
699
+ if (firstDay.getTime() > lastDay.getTime()) {
700
+ return [];
701
+ }
702
+ const rows = [];
703
+ for (let cursor = new Date(firstDay); cursor <= lastDay; cursor = addMinutes(cursor, 24 * 60)) {
704
+ if (!template.weekDays.includes(cursor.getUTCDay())) {
705
+ continue;
706
+ }
707
+ const blockStart = addMinutes(new Date(cursor), template.startMinute);
708
+ const blockEnd = addMinutes(new Date(cursor), template.endMinute);
709
+ if (blockEnd.toISOString() <= query.from || blockStart.toISOString() >= query.to) {
710
+ continue;
711
+ }
712
+ rows.push(workBlockInstanceSchema.parse({
713
+ id: `wbinst_${template.id}_${dateOnly(cursor)}`,
714
+ templateId: template.id,
715
+ dateKey: dateOnly(cursor),
716
+ startAt: blockStart.toISOString(),
717
+ endAt: blockEnd.toISOString(),
718
+ title: template.title,
719
+ kind: template.kind,
720
+ color: template.color,
721
+ blockingState: template.blockingState,
722
+ calendarEventId: null,
723
+ createdAt: template.createdAt,
724
+ updatedAt: template.updatedAt
725
+ }));
726
+ }
727
+ return rows;
728
+ }
729
+ export function ensureWorkBlockInstancesInRange(_query) {
730
+ return [];
731
+ }
732
+ export function listWorkBlockInstances(query) {
733
+ return listWorkBlockTemplates()
734
+ .flatMap((template) => deriveWorkBlockInstances(template, query))
735
+ .sort((left, right) => left.startAt.localeCompare(right.startAt) || left.title.localeCompare(right.title));
736
+ }
737
+ export function listTaskTimeboxes(query) {
738
+ const clauses = ["ends_at > ?", "starts_at < ?"];
739
+ const params = [query.from, query.to];
740
+ if (query.taskId) {
741
+ clauses.push("task_id = ?");
742
+ params.push(query.taskId);
743
+ }
744
+ if (query.projectId) {
745
+ clauses.push("project_id = ?");
746
+ params.push(query.projectId);
747
+ }
748
+ const rows = getDatabase()
749
+ .prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
750
+ starts_at, ends_at, override_reason, created_at, updated_at
751
+ FROM task_timeboxes
752
+ WHERE ${clauses.join(" AND ")}
753
+ ORDER BY starts_at ASC`)
754
+ .all(...params);
755
+ return rows.map(mapTimebox);
756
+ }
757
+ export function getTaskTimeboxById(timeboxId) {
758
+ const row = getDatabase()
759
+ .prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
760
+ starts_at, ends_at, override_reason, created_at, updated_at
761
+ FROM task_timeboxes
762
+ WHERE id = ?`)
763
+ .get(timeboxId);
764
+ return row ? mapTimebox(row) : undefined;
765
+ }
766
+ export function createTaskTimebox(input) {
767
+ const now = nowIso();
768
+ const id = `timebox_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
769
+ getDatabase()
770
+ .prepare(`INSERT INTO task_timeboxes (
771
+ id, task_id, project_id, connection_id, calendar_id, linked_task_run_id, status, source, title, starts_at, ends_at, override_reason, created_at, updated_at
772
+ )
773
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
774
+ .run(id, input.taskId, input.projectId ?? null, input.connectionId ?? null, input.calendarId ?? null, input.linkedTaskRunId ?? null, input.status ?? "planned", input.source ?? "manual", input.title, input.startsAt, input.endsAt, input.overrideReason ?? null, now, now);
775
+ return getTaskTimeboxById(id);
776
+ }
777
+ export function updateTaskTimebox(timeboxId, patch) {
778
+ const current = getTaskTimeboxById(timeboxId);
779
+ if (!current) {
780
+ return undefined;
781
+ }
782
+ const next = {
783
+ connectionId: patch.connectionId === undefined ? current.connectionId : patch.connectionId,
784
+ calendarId: patch.calendarId === undefined ? current.calendarId : patch.calendarId,
785
+ remoteEventId: patch.remoteEventId === undefined ? current.remoteEventId : patch.remoteEventId,
786
+ linkedTaskRunId: patch.linkedTaskRunId === undefined ? current.linkedTaskRunId : patch.linkedTaskRunId,
787
+ status: patch.status ?? current.status,
788
+ source: patch.source ?? current.source,
789
+ title: patch.title ?? current.title,
790
+ startsAt: patch.startsAt ?? current.startsAt,
791
+ endsAt: patch.endsAt ?? current.endsAt,
792
+ overrideReason: patch.overrideReason === undefined ? current.overrideReason : patch.overrideReason,
793
+ updatedAt: nowIso()
794
+ };
795
+ getDatabase()
796
+ .prepare(`UPDATE task_timeboxes
797
+ SET connection_id = ?, calendar_id = ?, remote_event_id = ?, linked_task_run_id = ?, status = ?, source = ?, title = ?,
798
+ starts_at = ?, ends_at = ?, override_reason = ?, updated_at = ?
799
+ WHERE id = ?`)
800
+ .run(next.connectionId, next.calendarId, next.remoteEventId, next.linkedTaskRunId, next.status, next.source, next.title, next.startsAt, next.endsAt, next.overrideReason, next.updatedAt, timeboxId);
801
+ return getTaskTimeboxById(timeboxId);
802
+ }
803
+ export function deleteTaskTimebox(timeboxId) {
804
+ const current = getTaskTimeboxById(timeboxId);
805
+ if (!current) {
806
+ return undefined;
807
+ }
808
+ getDatabase().prepare(`DELETE FROM task_timeboxes WHERE id = ?`).run(timeboxId);
809
+ return current;
810
+ }
811
+ export function findCoveringTimeboxForTask(taskId, at) {
812
+ const row = getDatabase()
813
+ .prepare(`SELECT id, task_id, project_id, connection_id, calendar_id, remote_event_id, linked_task_run_id, status, source, title,
814
+ starts_at, ends_at, override_reason, created_at, updated_at
815
+ FROM task_timeboxes
816
+ WHERE task_id = ? AND starts_at <= ? AND ends_at >= ?
817
+ ORDER BY starts_at DESC
818
+ LIMIT 1`)
819
+ .get(taskId, at.toISOString(), at.toISOString());
820
+ return row ? mapTimebox(row) : undefined;
821
+ }
822
+ export function bindTaskRunToTimebox(input) {
823
+ return runInTransaction(() => {
824
+ const existing = findCoveringTimeboxForTask(input.taskId, input.startedAt);
825
+ const startsAt = existing?.startsAt ?? input.startedAt.toISOString();
826
+ const endsAt = existing?.endsAt ??
827
+ addMinutes(input.startedAt, Math.max(15, Math.ceil((input.plannedDurationSeconds ?? 30 * 60) / 60))).toISOString();
828
+ if (existing) {
829
+ return updateTaskTimebox(existing.id, {
830
+ linkedTaskRunId: input.taskRunId,
831
+ status: "active",
832
+ title: input.title,
833
+ startsAt,
834
+ endsAt,
835
+ overrideReason: input.overrideReason ?? existing.overrideReason
836
+ });
837
+ }
838
+ return createTaskTimebox({
839
+ taskId: input.taskId,
840
+ projectId: input.projectId ?? null,
841
+ linkedTaskRunId: input.taskRunId,
842
+ status: "active",
843
+ source: "live_run",
844
+ title: input.title,
845
+ startsAt,
846
+ endsAt,
847
+ overrideReason: input.overrideReason ?? null
848
+ });
849
+ });
850
+ }
851
+ export function heartbeatTaskRunTimebox(taskRunId, patch) {
852
+ const row = getDatabase()
853
+ .prepare(`SELECT id
854
+ FROM task_timeboxes
855
+ WHERE linked_task_run_id = ?
856
+ ORDER BY updated_at DESC
857
+ LIMIT 1`)
858
+ .get(taskRunId);
859
+ if (!row) {
860
+ return undefined;
861
+ }
862
+ return updateTaskTimebox(row.id, {
863
+ title: patch.title,
864
+ endsAt: patch.endsAt,
865
+ status: "active",
866
+ overrideReason: patch.overrideReason ?? undefined
867
+ });
868
+ }
869
+ export function finalizeTaskRunTimebox(taskRunId, status, endsAt) {
870
+ const row = getDatabase()
871
+ .prepare(`SELECT id
872
+ FROM task_timeboxes
873
+ WHERE linked_task_run_id = ?
874
+ ORDER BY updated_at DESC
875
+ LIMIT 1`)
876
+ .get(taskRunId);
877
+ if (!row) {
878
+ return undefined;
879
+ }
880
+ return updateTaskTimebox(row.id, {
881
+ status,
882
+ endsAt,
883
+ linkedTaskRunId: taskRunId
884
+ });
885
+ }
886
+ function matchKeywords(keywords, haystack) {
887
+ if (keywords.length === 0) {
888
+ return false;
889
+ }
890
+ const normalized = haystack.toLowerCase();
891
+ return keywords.some((keyword) => normalized.includes(keyword.toLowerCase()));
892
+ }
893
+ export function evaluateSchedulingForTask(task, at = new Date()) {
894
+ const project = task.projectId ? getProjectById(task.projectId) ?? null : null;
895
+ const effectiveRules = normalizeRules(task.schedulingRules ?? project?.schedulingRules);
896
+ const currentEvents = listCalendarEvents({
897
+ from: addMinutes(at, -1).toISOString(),
898
+ to: addMinutes(at, 1).toISOString()
899
+ }).filter((event) => event.startAt <= at.toISOString() && event.endAt >= at.toISOString());
900
+ const currentBlocks = listWorkBlockInstances({
901
+ from: addMinutes(at, -1).toISOString(),
902
+ to: addMinutes(at, 1).toISOString()
903
+ }).filter((block) => block.startAt <= at.toISOString() && block.endAt >= at.toISOString());
904
+ const conflicts = [];
905
+ for (const event of currentEvents) {
906
+ if ((event.calendarId ? effectiveRules.blockCalendarIds.includes(event.calendarId) : false) ||
907
+ effectiveRules.blockEventTypes.includes(event.eventType) ||
908
+ effectiveRules.blockAvailability.includes(event.availability) ||
909
+ matchKeywords(effectiveRules.blockEventKeywords, `${event.title}\n${event.description}\n${event.location}`)) {
910
+ conflicts.push(calendarContextConflictSchema.parse({
911
+ kind: "external_event",
912
+ id: event.id,
913
+ title: event.title,
914
+ reason: "The active calendar event blocks this task or project.",
915
+ startsAt: event.startAt,
916
+ endsAt: event.endAt
917
+ }));
918
+ }
919
+ }
920
+ for (const block of currentBlocks) {
921
+ if (effectiveRules.blockWorkBlockKinds.includes(block.kind) ||
922
+ (effectiveRules.allowWorkBlockKinds.length > 0 &&
923
+ !effectiveRules.allowWorkBlockKinds.includes(block.kind))) {
924
+ conflicts.push(calendarContextConflictSchema.parse({
925
+ kind: "work_block",
926
+ id: block.id,
927
+ title: block.title,
928
+ reason: "The current work block does not allow this task or project.",
929
+ startsAt: block.startAt,
930
+ endsAt: block.endAt
931
+ }));
932
+ }
933
+ }
934
+ const anyAllowRules = effectiveRules.allowWorkBlockKinds.length > 0 ||
935
+ effectiveRules.allowCalendarIds.length > 0 ||
936
+ effectiveRules.allowEventTypes.length > 0 ||
937
+ effectiveRules.allowEventKeywords.length > 0 ||
938
+ effectiveRules.allowAvailability.length > 0;
939
+ let allowSatisfied = !anyAllowRules;
940
+ if (anyAllowRules) {
941
+ const syntheticFreeAllowed = effectiveRules.allowAvailability.includes("free") &&
942
+ currentEvents.every((event) => event.availability !== "busy");
943
+ allowSatisfied = syntheticFreeAllowed;
944
+ if (!allowSatisfied) {
945
+ allowSatisfied = currentBlocks.some((block) => effectiveRules.allowWorkBlockKinds.includes(block.kind));
946
+ }
947
+ if (!allowSatisfied) {
948
+ allowSatisfied = currentEvents.some((event) => (effectiveRules.allowCalendarIds.length === 0 ||
949
+ (event.calendarId ? effectiveRules.allowCalendarIds.includes(event.calendarId) : false)) &&
950
+ (effectiveRules.allowEventTypes.length === 0 ||
951
+ effectiveRules.allowEventTypes.includes(event.eventType)) &&
952
+ (effectiveRules.allowAvailability.length === 0 ||
953
+ effectiveRules.allowAvailability.includes(event.availability)) &&
954
+ (effectiveRules.allowEventKeywords.length === 0 ||
955
+ matchKeywords(effectiveRules.allowEventKeywords, `${event.title}\n${event.description}\n${event.location}`)));
956
+ }
957
+ }
958
+ if (!allowSatisfied) {
959
+ conflicts.push({
960
+ kind: currentBlocks[0] ? "work_block" : "external_event",
961
+ id: currentBlocks[0]?.id ?? currentEvents[0]?.id ?? "calendar_now",
962
+ title: currentBlocks[0]?.title ?? currentEvents[0]?.title ?? "Current context",
963
+ reason: "The current calendar context does not match the allowed rules for this task or project.",
964
+ startsAt: currentBlocks[0]?.startAt ?? currentEvents[0]?.startAt ?? at.toISOString(),
965
+ endsAt: currentBlocks[0]?.endAt ?? currentEvents[0]?.endAt ?? at.toISOString()
966
+ });
967
+ }
968
+ return {
969
+ blocked: conflicts.length > 0,
970
+ effectiveRules,
971
+ conflicts
972
+ };
973
+ }
974
+ function collectBusyIntervals(query) {
975
+ const busyIntervals = [];
976
+ for (const event of listCalendarEvents(query)) {
977
+ if (event.status !== "cancelled" && event.availability === "busy") {
978
+ busyIntervals.push({ startAt: event.startAt, endAt: event.endAt });
979
+ }
980
+ }
981
+ for (const block of listWorkBlockInstances(query)) {
982
+ if (block.blockingState === "blocked") {
983
+ busyIntervals.push({ startAt: block.startAt, endAt: block.endAt });
984
+ }
985
+ }
986
+ for (const timebox of listTaskTimeboxes(query)) {
987
+ if (timebox.status !== "cancelled") {
988
+ busyIntervals.push({ startAt: timebox.startsAt, endAt: timebox.endsAt });
989
+ }
990
+ }
991
+ return busyIntervals.sort((left, right) => left.startAt.localeCompare(right.startAt));
992
+ }
993
+ function hasOverlap(busyIntervals, startsAt, endsAt) {
994
+ return busyIntervals.some((interval) => Date.parse(interval.startAt) < endsAt.getTime() &&
995
+ Date.parse(interval.endAt) > startsAt.getTime());
996
+ }
997
+ export function suggestTaskTimeboxes(taskId, options = {}) {
998
+ const task = getTaskById(taskId);
999
+ if (!task) {
1000
+ return [];
1001
+ }
1002
+ const from = options.from ? new Date(options.from) : new Date();
1003
+ const to = options.to ? new Date(options.to) : addMinutes(from, 14 * 24 * 60);
1004
+ const durationMinutes = Math.max(15, Math.ceil(((task.plannedDurationSeconds ?? 30 * 60) / 60)));
1005
+ const query = { from: from.toISOString(), to: to.toISOString() };
1006
+ ensureWorkBlockInstancesInRange(query);
1007
+ const busyIntervals = collectBusyIntervals(query);
1008
+ const allowedBlocks = listWorkBlockInstances(query).filter((block) => block.blockingState === "allowed");
1009
+ const suggestions = [];
1010
+ const candidateWindows = allowedBlocks.length > 0
1011
+ ? allowedBlocks.map((block) => ({ start: new Date(block.startAt), end: new Date(block.endAt) }))
1012
+ : Array.from({ length: 14 }, (_, index) => {
1013
+ const day = addMinutes(new Date(from), index * 24 * 60);
1014
+ const start = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 8, 0, 0));
1015
+ const end = new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), 18, 0, 0));
1016
+ return { start, end };
1017
+ });
1018
+ for (const window of candidateWindows) {
1019
+ for (let cursor = new Date(window.start); cursor.getTime() + durationMinutes * 60 * 1000 <= window.end.getTime(); cursor = addMinutes(cursor, 30)) {
1020
+ const slotEnd = addMinutes(cursor, durationMinutes);
1021
+ if (hasOverlap(busyIntervals, cursor, slotEnd)) {
1022
+ continue;
1023
+ }
1024
+ const evaluation = evaluateSchedulingForTask(task, addMinutes(cursor, Math.floor(durationMinutes / 2)));
1025
+ if (evaluation.blocked) {
1026
+ continue;
1027
+ }
1028
+ suggestions.push(taskTimeboxSchema.parse({
1029
+ id: `suggested_${task.id}_${cursor.getTime()}`,
1030
+ taskId: task.id,
1031
+ projectId: task.projectId,
1032
+ connectionId: null,
1033
+ calendarId: null,
1034
+ remoteEventId: null,
1035
+ linkedTaskRunId: null,
1036
+ status: "planned",
1037
+ source: "suggested",
1038
+ title: task.title,
1039
+ startsAt: cursor.toISOString(),
1040
+ endsAt: slotEnd.toISOString(),
1041
+ overrideReason: null,
1042
+ createdAt: nowIso(),
1043
+ updatedAt: nowIso()
1044
+ }));
1045
+ if (suggestions.length >= (options.limit ?? 6)) {
1046
+ return suggestions;
1047
+ }
1048
+ }
1049
+ }
1050
+ return suggestions;
1051
+ }
1052
+ export function getCalendarOverview(query) {
1053
+ ensureWorkBlockInstancesInRange(query);
1054
+ return calendarOverviewPayloadSchema.parse({
1055
+ generatedAt: nowIso(),
1056
+ providers: [
1057
+ {
1058
+ provider: "google",
1059
+ label: "Google Calendar",
1060
+ supportsDedicatedForgeCalendar: true,
1061
+ connectionHelp: "Use a Google refresh token plus client credentials to sync calendars and publish Forge-owned events and timeboxes."
1062
+ },
1063
+ {
1064
+ provider: "apple",
1065
+ label: "Apple Calendar",
1066
+ supportsDedicatedForgeCalendar: true,
1067
+ connectionHelp: "Use your Apple ID email and an app-specific password. Forge discovers the writable calendars from https://caldav.icloud.com."
1068
+ },
1069
+ {
1070
+ provider: "microsoft",
1071
+ label: "Exchange Online",
1072
+ supportsDedicatedForgeCalendar: false,
1073
+ connectionHelp: "Save the Microsoft client ID and redirect URI in Calendar settings first, then sign in with Microsoft. Forge mirrors the selected calendars in read-only mode."
1074
+ },
1075
+ {
1076
+ provider: "caldav",
1077
+ label: "Custom CalDAV",
1078
+ supportsDedicatedForgeCalendar: true,
1079
+ connectionHelp: "Use an account-level CalDAV base URL, then let Forge discover the calendars before selecting sync and write targets."
1080
+ }
1081
+ ],
1082
+ connections: listCalendarConnections().map(({ credentialsSecretId: _secret, ...connection }) => connection),
1083
+ calendars: listCalendars(),
1084
+ events: listCalendarEvents(query),
1085
+ workBlockTemplates: listWorkBlockTemplates(),
1086
+ workBlockInstances: listWorkBlockInstances(query),
1087
+ timeboxes: listTaskTimeboxes(query)
1088
+ });
1089
+ }
1090
+ export function recordCalendarActivity(eventType, entityType, entityId, title, description, context, metadata = {}) {
1091
+ recordActivityEvent({
1092
+ entityType,
1093
+ entityId,
1094
+ eventType,
1095
+ title,
1096
+ description,
1097
+ actor: context.actor ?? null,
1098
+ source: context.source,
1099
+ metadata
1100
+ });
1101
+ }