@agent-native/core 0.0.0-beta-20260819191813 → 0.0.0-beta-20260819204836

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 (60) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +84 -0
  3. package/corpus/templates/calendar/AGENTS.md +5 -0
  4. package/corpus/templates/calendar/actions/delete-event.ts +1 -1
  5. package/corpus/templates/calendar/actions/delete-events.ts +508 -0
  6. package/corpus/templates/calendar/actions/event-action-helpers.ts +1 -1
  7. package/corpus/templates/calendar/actions/list-events.ts +53 -0
  8. package/corpus/templates/calendar/changelog/2026-08-18-the-agent-can-now-remove-many-meetings-at-once-such-as-every.md +6 -0
  9. package/corpus/templates/calendar/server/lib/event-weekday.ts +112 -0
  10. package/corpus/templates/factory/AGENTS.md +2 -1
  11. package/corpus/templates/factory/actions/get-factory-graph-version.ts +69 -0
  12. package/corpus/templates/factory/actions/list-factory-graph-versions.ts +82 -0
  13. package/corpus/templates/factory/actions/restore-factory-graph-version.ts +163 -0
  14. package/corpus/templates/factory/actions/save-factory-graph.ts +93 -60
  15. package/corpus/templates/factory/app/components/factory/FactoryHistoryView.tsx +709 -0
  16. package/corpus/templates/factory/app/components/factory/FactoryInspector.tsx +62 -2
  17. package/corpus/templates/factory/app/components/ui/alert-dialog.tsx +1 -0
  18. package/corpus/templates/factory/app/i18n/en-US.ts +43 -0
  19. package/corpus/templates/factory/app/routes/factory.tsx +115 -15
  20. package/corpus/templates/factory/server/plugins/agent-chat.ts +3 -1
  21. package/dist/notifications/routes.d.ts +3 -3
  22. package/dist/observability/routes.d.ts +6 -6
  23. package/dist/server/transcribe-voice.d.ts +1 -1
  24. package/docs/content/actions-access-control.mdx +136 -2
  25. package/docs/content/actions-agent-tools.mdx +101 -0
  26. package/docs/content/actions-defining.mdx +2 -2
  27. package/docs/content/audit-log.mdx +1 -1
  28. package/docs/content/authentication.mdx +1 -1
  29. package/docs/content/cloudflare.mdx +43 -0
  30. package/docs/content/creating-templates.mdx +1 -1
  31. package/docs/content/deploy-an-app.mdx +109 -0
  32. package/docs/content/deployment-environment-variables.mdx +101 -0
  33. package/docs/content/deployment.mdx +125 -792
  34. package/docs/content/docs-components.mdx +2 -2
  35. package/docs/content/environment-variables.mdx +41 -41
  36. package/docs/content/file-uploads.mdx +74 -40
  37. package/docs/content/harness-agents.mdx +2 -2
  38. package/docs/content/internationalization.mdx +61 -65
  39. package/docs/content/key-concepts.mdx +3 -3
  40. package/docs/content/multi-app-workspace.mdx +2 -2
  41. package/docs/content/netlify.mdx +47 -0
  42. package/docs/content/node-docker.mdx +138 -0
  43. package/docs/content/organizations-teams-permissions.mdx +5 -5
  44. package/docs/content/other-platforms.mdx +27 -0
  45. package/docs/content/ssr-caching.mdx +45 -0
  46. package/docs/content/template-assets-developers.mdx +2 -2
  47. package/docs/content/template-chat-developers.mdx +2 -2
  48. package/docs/content/template-content-developers.mdx +2 -2
  49. package/docs/content/{local-file-mode.mdx → template-content-local-files.mdx} +2 -1
  50. package/docs/content/template-content-sync.mdx +2 -2
  51. package/docs/content/toolkit-agent-ux.mdx +1 -1
  52. package/docs/content/toolkit-resources.mdx +2 -2
  53. package/docs/content/updating-ui-in-production.mdx +35 -0
  54. package/docs/content/vercel.mdx +38 -0
  55. package/docs/content/what-is-agent-native.mdx +1 -1
  56. package/docs/content/workspace-deployment.mdx +350 -0
  57. package/package.json +3 -3
  58. package/docs/content/agents.mdx +0 -9
  59. package/docs/content/database.mdx +0 -317
  60. package/docs/content/human-approval.mdx +0 -156
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8465
34
+ - template files: 8473
@@ -346,6 +346,88 @@ pnpm action delete-event --id google-event-id --scope thisAndFollowing
346
346
  pnpm action delete-event --id google-event-id --removeOnly true
347
347
  ```
348
348
 
349
+ One event only. For more than one, use `delete-events`.
350
+
351
+ ### delete-events
352
+
353
+ Every "remove all …" / "clear …" request goes here, in **one** call. Looping
354
+ `delete-event` per event cannot finish a real weekend cleanup inside a hosted
355
+ foreground run — that is the failure a user sees as "the agent stopped before
356
+ finishing" — and a partial loop leaves the calendar half-cleaned with no record
357
+ of which events survived. See the `reliable-mutations` skill.
358
+
359
+ Select by range plus `--daysOfWeek` and/or `--query`, or pass explicit `--ids`.
360
+ A filtered selection needs **both** `--from` and `--to` — a one-sided range would
361
+ silently widen or shrink a destructive request. Preview with `--dryRun true`,
362
+ show the user the matched list, then repeat the same call without `--dryRun`.
363
+
364
+ ```bash
365
+ # What would go?
366
+ pnpm action delete-events \
367
+ --from 2026-04-01 --to 2026-05-01 \
368
+ --daysOfWeek saturday,sunday \
369
+ --dryRun true
370
+
371
+ # Delete it
372
+ pnpm action delete-events \
373
+ --from 2026-04-01 --to 2026-05-01 \
374
+ --daysOfWeek saturday,sunday
375
+
376
+ # Explicit ids from a previous list-events/search-events
377
+ pnpm action delete-events --ids google-a,google-b --accountEmail secondary@example.com
378
+ ```
379
+
380
+ `--daysOfWeek` accepts full or 3-letter day names, `weekend`, or `weekdays`, and
381
+ resolves each event's day in the timezone the calendar is pinned to (the saved
382
+ `timezone` setting, then the browser's) — a Sunday 5pm America/Los_Angeles
383
+ meeting is Monday in UTC, so a UTC comparison deletes the wrong day. Pass
384
+ `--timezone` to override; an invalid zone is rejected, never silently treated as
385
+ UTC. Events are selected by where they **start**: `--from` inclusive, `--to`
386
+ exclusive, so an event starting exactly on the end bound, or a multi-day event
387
+ that began before `--from`, is left alone. Both bounds must be real dates; a
388
+ blank or impossible date (`2026-02-30`, with or without a time) is rejected
389
+ rather than rolled forward.
390
+
391
+ `--removeOnly true` cannot honor `--scope thisAndFollowing`: Google only lets a
392
+ non-organizer drop one occurrence at a time, so that pair is rejected instead of
393
+ reporting a series-wide removal that did not happen. Use `--scope single` per
394
+ occurrence, or `--scope all` to drop the whole series from your own calendar.
395
+
396
+ A filtered selection only ever removes the matched occurrences, so it accepts
397
+ `--scope single`. `all` and `thisAndFollowing` act on a whole recurring series —
398
+ which for a daily series would also delete the weekdays the user kept, and would
399
+ not match the dry-run preview — so they require explicit `--ids` or
400
+ `delete-event`, and take exactly one id per call because they mutate the series
401
+ master.
402
+
403
+ The commit re-reads the calendar, so it acts on the user's intent ("the weekend
404
+ is clear") rather than a frozen list — an event that moved onto a Saturday
405
+ between preview and confirmation is still removed. When the user should get
406
+ exactly the reviewed set and nothing else, pass the `--ids` from the dry-run
407
+ result instead of repeating the filter.
408
+
409
+ The result is a per-event report, not a boolean. Read `deleted`, `failed`, and
410
+ `skipped` and give the user the counts; a `failed` entry carries the provider
411
+ error and a `skipped` entry says why the app cannot delete it (ICS feeds are
412
+ read-only; a booking, including the Google event backing one, is cancelled from
413
+ the booking so that deleting the event cannot leave the booking confirmed — this
414
+ holds for explicit `--ids` too). Never report a bulk delete as done from the
415
+ absence of a thrown error.
416
+
417
+ `coverageComplete: false` plus `unreadableSources` means a feed could not be
418
+ read, so the sweep does not account for everything the user can see. Deletable
419
+ events are still deleted — a third-party feed outage should not block a cleanup,
420
+ and ICS events were never deletable — but say plainly that the feed was not
421
+ covered instead of reporting a clean pass.
422
+
423
+ The action refuses rather than guesses when the calendar read was incomplete (an
424
+ expired account token) or when more than 200 events match — narrow the filter
425
+ and run again. An `unreadableSources` entry means an ICS feed could not be read,
426
+ so mention that the sweep did not cover it.
427
+
428
+ `delete-events` only reads the signed-in user's own accounts, bookings, and
429
+ subscribed feeds; it cannot touch an overlaid person's calendar.
430
+
349
431
  ### rsvp-event
350
432
 
351
433
  Accept, decline, or tentatively accept an invitation with the event's
@@ -428,6 +510,8 @@ When the user says:
428
510
  | "move/rename/update a meeting" | `update-event --id ...` |
429
511
  | "add Zoom to this meeting" | `update-event --id ... --addZoom=true` |
430
512
  | "delete/remove a meeting" | `delete-event --id ...` |
513
+ | "remove all Saturday and Sunday meetings" | `delete-events --from ... --to ... --daysOfWeek saturday,sunday` |
514
+ | "clear my calendar next week" | `delete-events --from ... --to ...` (preview with `--dryRun true` first) |
431
515
  | "remove weekends from a daily recurring event" | `update-event --id ... --recurrence "RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR"` |
432
516
  | "what's coming up" | `list-events` (uses default 30-day forward window) |
433
517
 
@@ -31,6 +31,11 @@ Detailed event, availability, booking, storage, and UI rules live in
31
31
  Google Calendar. Return its link to the user; do not `fetch`
32
32
  `/_agent-native/google/auth-url` from the agent backend because that route
33
33
  requires the signed-in browser session.
34
+ - Satisfy a multi-event request with one batch call, never a loop of per-event
35
+ writes: `delete-events` handles every "remove all …" / "clear …" request,
36
+ including day-of-week filters, and `delete-event` is for exactly one event.
37
+ Preview with `dryRun` first, then report the returned `deleted` / `failed` /
38
+ `skipped` counts. See `event-management` and `reliable-mutations`.
34
39
  - The action schema is authoritative when a parameter is unclear.
35
40
  - Use the current date from runtime context, not a visible calendar date, when
36
41
  the user says today/tomorrow/yesterday.
@@ -15,7 +15,7 @@ import {
15
15
 
16
16
  export default defineAction({
17
17
  description:
18
- "Delete or remove a Google Calendar event. For recurring events, choose just this instance, all events in the series, or this and following events.",
18
+ "Delete or remove ONE Google Calendar event. For recurring events, choose just this instance, all events in the series, or this and following events. For more than one event — any 'remove all …' / 'clear …' request — use delete-events instead; never call this in a loop.",
19
19
  schema: z.object({
20
20
  id: z
21
21
  .string()
@@ -0,0 +1,508 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { getUserSetting } from "@agent-native/core/settings";
3
+ import { z } from "zod";
4
+
5
+ import {
6
+ eventWeekday,
7
+ matchesWeekdays,
8
+ normalizeWeekdays,
9
+ requireValidTimezone,
10
+ type WeekdayName,
11
+ } from "../server/lib/event-weekday.js";
12
+ import { zonedDateTimeToUtcIso } from "../server/lib/find-time.js";
13
+ import * as googleCalendar from "../server/lib/google-calendar.js";
14
+ import type { CalendarEvent } from "../shared/api.js";
15
+ import {
16
+ cliBoolean,
17
+ isValidDateOnly,
18
+ normalizeGoogleEventId,
19
+ requireActionUserEmail,
20
+ resolveOwnedAccountEmail,
21
+ } from "./event-action-helpers.js";
22
+ import {
23
+ findBookedGoogleEvents,
24
+ listCalendarEvents,
25
+ resolveCalendarEventRange,
26
+ } from "./list-events.js";
27
+
28
+ /**
29
+ * A bulk delete is the one calendar write where a wrong filter is unrecoverable,
30
+ * so the match set is capped rather than paged: over the cap the action refuses
31
+ * and asks the caller to narrow, instead of deleting the first N of an unknown
32
+ * number and reporting success.
33
+ */
34
+ const MAX_MATCHED_EVENTS = 200;
35
+ /** Google Calendar rate-limits per user, so fan out modestly rather than
36
+ * firing every delete at once and turning a clean batch into retries. */
37
+ const DELETE_CONCURRENCY = 4;
38
+
39
+ type Outcome = "deleted" | "matched" | "skipped" | "failed";
40
+
41
+ interface EventResult {
42
+ id: string;
43
+ title?: string;
44
+ start?: string;
45
+ weekday?: WeekdayName;
46
+ accountEmail?: string;
47
+ outcome: Outcome;
48
+ reason?: string;
49
+ }
50
+
51
+ type BookedEvent = { googleEventId: string; calendarAccountId: string | null };
52
+
53
+ /**
54
+ * Google event ids are scoped to a calendar, so a booking only protects the
55
+ * event on its own account. A booking whose account was never recorded still
56
+ * protects every match: reading that null as "some other account" would delete a
57
+ * booked event, which is the failure this guard exists to prevent.
58
+ */
59
+ function isBookedOnAccount(
60
+ booked: readonly BookedEvent[],
61
+ googleEventId: string,
62
+ accountEmail: string | undefined,
63
+ ): boolean {
64
+ return booked.some(
65
+ (row) =>
66
+ row.googleEventId === googleEventId &&
67
+ (!row.calendarAccountId ||
68
+ !accountEmail ||
69
+ row.calendarAccountId.trim().toLowerCase() ===
70
+ accountEmail.trim().toLowerCase()),
71
+ );
72
+ }
73
+
74
+ const BOOKED_EVENT_REASON =
75
+ "Is the Google event for an active booking; cancel the booking instead";
76
+
77
+ /** Why this app cannot delete an event, or undefined when it can. */
78
+ function undeletableReason(
79
+ event: CalendarEvent,
80
+ booked: readonly BookedEvent[],
81
+ ): string | undefined {
82
+ // The shared read hides a booking whose linked Google event is present, so
83
+ // deleting that Google event here would leave the booking row confirmed and
84
+ // the event would reappear on the calendar as a local booking.
85
+ if (
86
+ event.googleEventId &&
87
+ isBookedOnAccount(booked, event.googleEventId, event.accountEmail)
88
+ ) {
89
+ return BOOKED_EVENT_REASON;
90
+ }
91
+ if (event.source === "ical") {
92
+ return "Comes from a subscribed ICS feed, which is read-only";
93
+ }
94
+ if (event.source === "local") {
95
+ return "Is a booking; cancel it from the booking instead";
96
+ }
97
+ if (!event.googleEventId) {
98
+ return "Has no Google event id to delete";
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
104
+
105
+ /**
106
+ * Whether an event *starts* inside the requested range: `from` inclusive, `to`
107
+ * exclusive.
108
+ *
109
+ * The shared calendar read deliberately returns anything that overlaps the range
110
+ * so the UI can draw a multi-day event and an event beginning on the boundary.
111
+ * Neither is what a caller naming a range for a delete means, and the reported
112
+ * `weekday` comes from the start, so an event starting outside the range would be
113
+ * previewed under a day the caller never queried. Re-checking both bounds here
114
+ * keeps the delete inside the dates the caller actually named.
115
+ *
116
+ * An all-day start carries no instant, so it is anchored to local midnight in the
117
+ * range's own timezone: parsing it as a UTC date would sort it before the zone's
118
+ * midnight, and comparing it as a date string would drop a same-day all-day event
119
+ * from a range ending at midday.
120
+ */
121
+ function startsWithinRange(
122
+ start: string,
123
+ range: { from: string; to: string; timezone: string },
124
+ ): boolean {
125
+ const startMs = DATE_ONLY_RE.test(start)
126
+ ? new Date(zonedDateTimeToUtcIso(start, "00:00", range.timezone)).getTime()
127
+ : new Date(start).getTime();
128
+ return (
129
+ startMs >= new Date(range.from).getTime() &&
130
+ startMs < new Date(range.to).getTime()
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Reject a bound the shared resolver would silently reinterpret. An impossible
136
+ * day rolls forward instead of failing on both paths a bound can take —
137
+ * `Date.UTC` for a date-only value and `new Date` for a datetime, where V8
138
+ * turns `2026-02-30T00:00:00-08:00` into March 2 — so a destructive range would
139
+ * silently cover dates the caller never stated. Validated on the leading date
140
+ * for that reason; an out-of-range month or an unparseable value already fails
141
+ * in `normalizeDateBound`.
142
+ */
143
+ function requireExplicitBound(value: string, label: "from" | "to"): string {
144
+ const trimmed = value.trim();
145
+ if (!trimmed) throw new Error(`${label} cannot be blank.`);
146
+ const datePart = trimmed.slice(0, 10);
147
+ if (DATE_ONLY_RE.test(datePart) && !isValidDateOnly(datePart)) {
148
+ throw new Error(`${label} is not a real calendar date: ${datePart}`);
149
+ }
150
+ return trimmed;
151
+ }
152
+
153
+ async function mapWithConcurrency<T, R>(
154
+ items: readonly T[],
155
+ limit: number,
156
+ run: (item: T, index: number) => Promise<R>,
157
+ ): Promise<R[]> {
158
+ const results = new Array<R>(items.length);
159
+ let next = 0;
160
+ await Promise.all(
161
+ Array.from({ length: Math.min(limit, items.length) }, async () => {
162
+ while (next < items.length) {
163
+ const index = next++;
164
+ results[index] = await run(items[index], index);
165
+ }
166
+ }),
167
+ );
168
+ return results;
169
+ }
170
+
171
+ /**
172
+ * Which timezone decides an event's weekday, in the order the rest of the app
173
+ * uses it: an explicit argument, then the timezone the calendar is pinned to,
174
+ * then the browser's. Every layer is validated rather than normalized, because
175
+ * `normalizeTimezone`'s silent UTC fallback would move day boundaries under a
176
+ * delete without anyone being able to tell.
177
+ */
178
+ async function resolveFilterTimezone(
179
+ requested: string | undefined,
180
+ ownerEmail: string,
181
+ ): Promise<string | undefined> {
182
+ if (requested) return requireValidTimezone(requested);
183
+ const settings = (await getUserSetting(ownerEmail, "calendar-settings")) as {
184
+ timezone?: unknown;
185
+ } | null;
186
+ const saved = settings?.timezone;
187
+ if (typeof saved !== "string" || !saved.trim()) return undefined;
188
+ try {
189
+ return requireValidTimezone(saved.trim());
190
+ } catch {
191
+ throw new Error(
192
+ `The saved calendar timezone (${saved}) is not a valid IANA timezone, so weekday filtering cannot be trusted. Fix it in Settings or pass timezone explicitly.`,
193
+ );
194
+ }
195
+ }
196
+
197
+ export default defineAction({
198
+ description:
199
+ "Delete many calendar events in one call — the only supported way to satisfy a bulk request like 'remove all Saturday and Sunday meetings' or 'clear next week'. Never loop delete-event per event. Filter by date range plus daysOfWeek and/or a title query, or pass explicit ids. Call once with dryRun true to show the user exactly what matches, then once more without dryRun to delete. Weekdays are resolved in the calendar's timezone.",
200
+ schema: z.object({
201
+ ids: z
202
+ .array(z.string())
203
+ .max(MAX_MATCHED_EVENTS)
204
+ .optional()
205
+ .describe(
206
+ 'Explicit Google event ids, with or without the "google-" prefix. Omit to select by filter instead.',
207
+ ),
208
+ from: z
209
+ .string()
210
+ .optional()
211
+ .describe("Filter range start (ISO date or datetime)"),
212
+ to: z
213
+ .string()
214
+ .optional()
215
+ .describe("Filter range end, exclusive (ISO date or datetime)"),
216
+ daysOfWeek: z
217
+ .union([z.string(), z.array(z.string()).max(7)])
218
+ .optional()
219
+ .describe(
220
+ 'Days to match, e.g. ["saturday","sunday"], "sat,sun", or "weekend"',
221
+ ),
222
+ query: z
223
+ .string()
224
+ .max(500)
225
+ .optional()
226
+ .describe("Case-insensitive title/attendee/organizer filter"),
227
+ accountEmails: z
228
+ .array(z.string().email())
229
+ .min(1)
230
+ .max(20)
231
+ .optional()
232
+ .describe("Connected Google accounts to search; omitted searches all"),
233
+ accountEmail: z
234
+ .string()
235
+ .optional()
236
+ .describe("Account owning the events when passing explicit ids"),
237
+ timezone: z
238
+ .string()
239
+ .optional()
240
+ .describe(
241
+ "IANA timezone that defines the day boundaries; defaults to the saved calendar timezone",
242
+ ),
243
+ scope: z
244
+ .enum(["single", "all", "thisAndFollowing"])
245
+ .optional()
246
+ .default("single")
247
+ .describe(
248
+ "Recurring-event delete scope. Filtered selection allows single only; all and thisAndFollowing require explicit ids because they act on the whole series.",
249
+ ),
250
+ sendUpdates: z
251
+ .enum(["all", "none"])
252
+ .optional()
253
+ .default("none")
254
+ .describe("Whether Google should notify attendees of each cancellation"),
255
+ removeOnly: cliBoolean
256
+ .optional()
257
+ .describe(
258
+ "Use true when the user is not the organizer and wants the events removed from their own calendar only.",
259
+ ),
260
+ dryRun: cliBoolean
261
+ .optional()
262
+ .describe("Return the matched events without deleting anything"),
263
+ }),
264
+ toolCallable: false,
265
+ run: async (args) => {
266
+ const ownerEmail = requireActionUserEmail();
267
+ if (!(await googleCalendar.isConnected(ownerEmail))) {
268
+ throw new Error(
269
+ "Google Calendar not connected. Connect via Settings first.",
270
+ );
271
+ }
272
+
273
+ const weekdays = normalizeWeekdays(args.daysOfWeek);
274
+ const hasIds = !!args.ids?.length;
275
+ if (hasIds && (args.from || args.to || weekdays.length > 0 || args.query)) {
276
+ throw new Error(
277
+ "Pass either explicit ids or a filter (from/to, daysOfWeek, query), not both.",
278
+ );
279
+ }
280
+ // Both bounds, not one: a lone `to` would silently start the range at today
281
+ // and a lone `from` would silently shrink it to a single day, so an
282
+ // incomplete destructive request would delete a range nobody asked for.
283
+ // Trim and validate first, because the shared resolver trims its own inputs
284
+ // and a whitespace-only bound would pass a truthiness check here and then
285
+ // resolve to today's range.
286
+ const from = args.from
287
+ ? requireExplicitBound(args.from, "from")
288
+ : undefined;
289
+ const to = args.to ? requireExplicitBound(args.to, "to") : undefined;
290
+ if (!hasIds && !(from && to)) {
291
+ throw new Error("A bulk delete needs both from and to, or explicit ids.");
292
+ }
293
+ // Google expands a recurring series into instances, so a weekend filter can
294
+ // match several occurrences of one daily series. Deleting with scope "all"
295
+ // would remove the whole series including the weekdays the user kept, and
296
+ // "thisAndFollowing" would have those occurrences race to rewrite the same
297
+ // master RRULE. Either way the dry-run preview would understate what
298
+ // happens, so a filtered selection is restricted to the matched occurrences.
299
+ // `removeEventFromCalendar` can only drop the named occurrence for
300
+ // "thisAndFollowing" — its own comment says so — so accepting the pair would
301
+ // report a series-wide removal while later occurrences stayed on the
302
+ // calendar. "all" resolves the master and is honored, so only this pair is
303
+ // rejected.
304
+ if (args.removeOnly && args.scope === "thisAndFollowing") {
305
+ throw new Error(
306
+ 'removeOnly cannot honor scope "thisAndFollowing" — Google only lets a non-organizer drop one occurrence at a time. Use scope single per occurrence, or scope all to remove the whole series from your calendar.',
307
+ );
308
+ }
309
+ // A series scope acts on the series master, so batching several ids under it
310
+ // is incoherent: two occurrences of one series would either race to rewrite
311
+ // the same RRULE cutoff or have the second call 404 on an already-deleted
312
+ // master, and the per-event report would be wrong either way. One id per
313
+ // series operation removes the race by construction.
314
+ if (hasIds && args.scope !== "single" && args.ids!.length > 1) {
315
+ throw new Error(
316
+ `scope "${args.scope}" acts on a whole recurring series, so it takes exactly one id. Call it once per series, or use scope single to remove specific occurrences.`,
317
+ );
318
+ }
319
+ if (!hasIds && args.scope !== "single") {
320
+ throw new Error(
321
+ `scope "${args.scope}" acts on a whole recurring series, which a filtered bulk delete cannot preview. Use scope single here, or pass the specific event as ids (or call delete-event) to change a series.`,
322
+ );
323
+ }
324
+
325
+ const range = resolveCalendarEventRange({
326
+ from,
327
+ to,
328
+ timezone: await resolveFilterTimezone(args.timezone, ownerEmail),
329
+ });
330
+
331
+ let targets: Array<{
332
+ googleEventId: string;
333
+ accountEmail: string;
334
+ display: EventResult;
335
+ }> = [];
336
+ const results: EventResult[] = [];
337
+ // A feed that would only have contributed a skipped row still means the
338
+ // report does not cover everything on screen; say so rather than imply it.
339
+ const unreadableSources: Array<{ name: string; error: string }> = [];
340
+
341
+ if (hasIds) {
342
+ const accountEmail = await resolveOwnedAccountEmail(
343
+ args.accountEmail,
344
+ ownerEmail,
345
+ );
346
+ // Two spellings of one id ("google-a" and "a") would otherwise enqueue two
347
+ // writes for the same event: one succeeds, the other 404s, and the report
348
+ // claims a failure that never happened.
349
+ const requested = Array.from(
350
+ new Set(args.ids!.map(normalizeGoogleEventId)),
351
+ );
352
+ // Explicit ids get the same booking protection as a filtered selection:
353
+ // naming the event directly does not make leaving its booking confirmed
354
+ // any less of a silent inconsistency.
355
+ const booked = await findBookedGoogleEvents(requested);
356
+ for (const googleEventId of requested) {
357
+ const display: EventResult = {
358
+ id: `google-${googleEventId}`,
359
+ accountEmail,
360
+ outcome: "matched",
361
+ };
362
+ if (isBookedOnAccount(booked, googleEventId, accountEmail)) {
363
+ results.push({
364
+ ...display,
365
+ outcome: "skipped",
366
+ reason: BOOKED_EVENT_REASON,
367
+ });
368
+ continue;
369
+ }
370
+ targets.push({ googleEventId, accountEmail, display });
371
+ }
372
+ } else {
373
+ // Read every source the user can see, not just Google. A weekend event
374
+ // from an ICS feed or a standalone booking is not deletable here, and
375
+ // narrowing the read to Google would drop it from the report entirely --
376
+ // so "deleted 3 of 3" comes back while the user still sees a fourth.
377
+ const listed = await listCalendarEvents(
378
+ { query: args.query, accountEmails: args.accountEmails },
379
+ { range },
380
+ );
381
+ // A provider read that partially failed is not an empty weekend. Deleting
382
+ // "everything that matched" out of an incomplete inventory would report a
383
+ // finished cleanup over events it never saw.
384
+ if (listed.errors.length > 0) {
385
+ throw new Error(
386
+ `Cannot bulk delete from an incomplete calendar read: ${listed.errors
387
+ .map((entry) => `${entry.email}: ${entry.error}`)
388
+ .join("; ")}`,
389
+ );
390
+ }
391
+
392
+ for (const feed of listed.icalErrors) {
393
+ unreadableSources.push({ name: feed.name, error: feed.error });
394
+ }
395
+
396
+ const matched = listed.events.filter(
397
+ (event) =>
398
+ startsWithinRange(event.start, range) &&
399
+ matchesWeekdays(event.start, range.timezone, weekdays),
400
+ );
401
+ if (matched.length > MAX_MATCHED_EVENTS) {
402
+ throw new Error(
403
+ `${matched.length} events match, over the ${MAX_MATCHED_EVENTS} limit for one bulk delete. Narrow the range or filter and run again.`,
404
+ );
405
+ }
406
+
407
+ const booked = await findBookedGoogleEvents(
408
+ matched
409
+ .map((event) => event.googleEventId)
410
+ .filter((id): id is string => Boolean(id)),
411
+ );
412
+
413
+ for (const event of matched) {
414
+ const display: EventResult = {
415
+ id: event.googleEventId ? `google-${event.googleEventId}` : event.id,
416
+ title: event.title,
417
+ start: event.start,
418
+ weekday: eventWeekday(event.start, range.timezone),
419
+ accountEmail: event.accountEmail,
420
+ outcome: "matched",
421
+ };
422
+ const reason = undeletableReason(event, booked);
423
+ if (reason) {
424
+ results.push({ ...display, outcome: "skipped", reason });
425
+ continue;
426
+ }
427
+ targets.push({
428
+ googleEventId: event.googleEventId!,
429
+ accountEmail: event.accountEmail ?? ownerEmail,
430
+ display,
431
+ });
432
+ }
433
+ }
434
+
435
+ const summaryBase = {
436
+ range: { from: range.from, to: range.to, timezone: range.timezone },
437
+ daysOfWeek: weekdays,
438
+ matched: targets.length + results.length,
439
+ scope: args.scope,
440
+ // Same contract as list-events: a source that could not be read means this
441
+ // sweep does not account for everything the user can see, and that must be
442
+ // impossible to mistake for a clean full pass.
443
+ coverageComplete: unreadableSources.length === 0,
444
+ ...(unreadableSources.length > 0 ? { unreadableSources } : {}),
445
+ };
446
+
447
+ if (args.dryRun) {
448
+ return {
449
+ ...summaryBase,
450
+ dryRun: true,
451
+ deleted: 0,
452
+ failed: 0,
453
+ skipped: results.length,
454
+ events: [...targets.map((target) => target.display), ...results],
455
+ };
456
+ }
457
+
458
+ const options = {
459
+ scope: args.scope,
460
+ sendUpdates: args.removeOnly ? ("none" as const) : args.sendUpdates,
461
+ };
462
+ const deleteResults = await mapWithConcurrency(
463
+ targets,
464
+ DELETE_CONCURRENCY,
465
+ async (target): Promise<EventResult> => {
466
+ const account = {
467
+ ownerEmail,
468
+ accountEmail: target.accountEmail,
469
+ };
470
+ try {
471
+ if (args.removeOnly) {
472
+ await googleCalendar.removeEventFromCalendar(
473
+ target.googleEventId,
474
+ account,
475
+ options,
476
+ );
477
+ } else {
478
+ await googleCalendar.deleteEvent(
479
+ target.googleEventId,
480
+ account,
481
+ options,
482
+ );
483
+ }
484
+ return { ...target.display, outcome: "deleted" };
485
+ } catch (error) {
486
+ return {
487
+ ...target.display,
488
+ outcome: "failed",
489
+ reason: error instanceof Error ? error.message : String(error),
490
+ };
491
+ }
492
+ },
493
+ );
494
+
495
+ const events = [...deleteResults, ...results];
496
+ return {
497
+ ...summaryBase,
498
+ dryRun: false,
499
+ deleted: deleteResults.filter((entry) => entry.outcome === "deleted")
500
+ .length,
501
+ failed: deleteResults.filter((entry) => entry.outcome === "failed")
502
+ .length,
503
+ skipped: results.length,
504
+ removedOnly: args.removeOnly ?? false,
505
+ events,
506
+ };
507
+ },
508
+ });
@@ -337,7 +337,7 @@ export function buildStatusEventFields(args: {
337
337
 
338
338
  const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
339
339
 
340
- function isValidDateOnly(value: string): boolean {
340
+ export function isValidDateOnly(value: string): boolean {
341
341
  if (!DATE_ONLY_PATTERN.test(value)) return false;
342
342
  const [year, month, day] = value.split("-").map(Number);
343
343
  const parsed = new Date(Date.UTC(year, month - 1, day));