What's broken
calendar.updateEvent cannot convert an all-day event to a timed one (or the reverse). Every such update fails with:
Invalid start time. (code 400)
This hits the most common reschedule request there is — "move this all-day reminder to 10am".
Version / location
workspace-server 0.0.8, current main.
workspace-server/src/services/CalendarService.ts, in updateEvent:
if (start) requestBody.start = start;
if (end) requestBody.end = end;
Root cause
updateEvent uses events.patch, which merges — including inside nested objects. start/end have two mutually exclusive representations (date for all-day, dateTime for timed). When the caller sends only { dateTime: ... } onto an event stored with { date: ... }, patch merges the two and the API sees both fields set on the same object, which it rejects with Invalid start time.
Converting between the two kinds requires explicitly clearing the opposite field with null (the patch-semantics way to delete a field), and no layer does that today.
Repro (clean tree)
- Create an all-day event:
await calendarService.createEvent({
summary: 'repro',
start: { date: '2026-08-21' },
end: { date: '2026-08-22' },
});
- Try to make it a timed event:
await calendarService.updateEvent({
eventId,
start: { dateTime: '2026-08-21T10:00:00-03:00' },
end: { dateTime: '2026-08-21T10:30:00-03:00' },
});
Negative control (unpatched main, verified 2026-08-20 against a real calendar): step 2 returns {"error":"global invalid: Invalid start time. (code 400)"}. The same dateTime pair sent to createEvent is accepted, so the values themselves are valid — the failure is specific to patching over an existing date.
The reverse direction (timed → all-day) fails the same way.
Suggested fix
Clear the representation the caller did not use before patching. validateUpdateEventInput already guarantees exactly one of the two arrives set:
function withOppositeDateFieldCleared(
field: calendar_v3.Schema$EventDateTime,
): calendar_v3.Schema$EventDateTime {
return field.dateTime
? { ...field, date: null }
: { ...field, dateTime: null };
}
// in updateEvent:
if (start) requestBody.start = withOppositeDateFieldCleared(start);
if (end) requestBody.end = withOppositeDateFieldCleared(end);
This is a no-op for updates that keep the same kind of event (the nulled field is already absent server-side), so existing behavior is preserved.
Verified against a real calendar with this change applied: all-day → timed succeeds, timed → all-day succeeds, and an update touching only summary leaves start/end untouched. Unit tests asserting the date: null / dateTime: null bodies go red if the clearing is removed.
Happy to share the test cases if useful. (Reporting as an issue with the fix inline rather than a PR.)
What's broken
calendar.updateEventcannot convert an all-day event to a timed one (or the reverse). Every such update fails with:This hits the most common reschedule request there is — "move this all-day reminder to 10am".
Version / location
workspace-server0.0.8, currentmain.workspace-server/src/services/CalendarService.ts, inupdateEvent:Root cause
updateEventusesevents.patch, which merges — including inside nested objects.start/endhave two mutually exclusive representations (datefor all-day,dateTimefor timed). When the caller sends only{ dateTime: ... }onto an event stored with{ date: ... }, patch merges the two and the API sees both fields set on the same object, which it rejects withInvalid start time.Converting between the two kinds requires explicitly clearing the opposite field with
null(the patch-semantics way to delete a field), and no layer does that today.Repro (clean tree)
Negative control (unpatched
main, verified 2026-08-20 against a real calendar): step 2 returns{"error":"global invalid: Invalid start time. (code 400)"}. The samedateTimepair sent tocreateEventis accepted, so the values themselves are valid — the failure is specific to patching over an existingdate.The reverse direction (timed → all-day) fails the same way.
Suggested fix
Clear the representation the caller did not use before patching.
validateUpdateEventInputalready guarantees exactly one of the two arrives set:This is a no-op for updates that keep the same kind of event (the nulled field is already absent server-side), so existing behavior is preserved.
Verified against a real calendar with this change applied: all-day → timed succeeds, timed → all-day succeeds, and an update touching only
summaryleavesstart/enduntouched. Unit tests asserting thedate: null/dateTime: nullbodies go red if the clearing is removed.Happy to share the test cases if useful. (Reporting as an issue with the fix inline rather than a PR.)