@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
215 lines • 9.93 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { Delta, FileChangeKind } from "../../types.js";
import { mountPrefixOf } from "../../utils/key_prefix.js";
import { compareCodePoints, sortedJson } from "../../utils/sort.js";
import { CALENDAR_FILE, eventTitle, makeEventFilename } from "../../vfs/gcal/event_entry.js";
import { changesBetween, changesUnder } from "../../events/delta.js";
import { textField } from "../../events/payload.js";
import { ROOT } from "../hierarchy/scope.js";
import { listed } from "./client.js";
import { bucketName, bucketStart, clampedHhmm, dayBounds, daysCovered, eventSpan } from "./day.js";
import { bucketZone, calendarIndex, calendarPayload, FREE_BUSY_ROLE, scopedBucket, } from "./readdir.js";
import { detectScope } from "./scope.js";
import { parseTime } from "../time_range.js";
/**
* The files an event is: one per day it covers that the mount's scope holds,
* named as the bucket listing names it. A bucket whose listing leaves the
* event out (it falls outside the mount's time range) holds none of it.
* Mirrors Python's `event_paths`.
*/
export function eventPaths(accessor, base, event, tz, freeBusy) {
const eventId = textField(event, 'id');
const span = eventSpan(event, tz);
if (eventId === null || eventId === '' || span === null)
return [];
const size = accessor.config.bucketDays;
const scope = accessor.timeRange;
const title = eventTitle(textField(event, 'summary'), freeBusy);
const paths = [];
for (const day of daysCovered(span, tz)) {
const bucket = bucketName(bucketStart(day, size), size);
let inScope;
try {
inScope = scopedBucket(accessor, bucket, tz, base);
}
catch (err) {
if (err.code !== 'ENOENT')
throw err;
console.debug(`gcal events skip ${base}/${bucket}: ${String(err)}`);
continue;
}
const [lo, hi] = dayBounds(inScope[0] ?? day, tz, inScope.length);
if (inScope.includes(day) &&
(!scope.bounded || listed(span, ...scope.clip(parseTime(lo), parseTime(hi))))) {
const name = makeEventFilename(eventId, clampedHhmm(span, day, tz), title, size > 1 ? day : null);
paths.push(`${base}/${bucket}/${name}`);
}
}
return paths;
}
/**
* Checkpointed delta over Calendar's incremental sync. Each calendar's
* checkpoint holds its sync token and, by event id, the files the event is and
* its `updated` stamp. An event names no file itself (its files follow from its
* start, its title and the days it covers), so the files it was are what the
* checkpoint remembers: an event moved or renamed is its old files leaving and
* its new ones appearing, and a deleted one comes back `cancelled`, which
* leaves every file it was. A token Calendar no longer honors (410) is answered
* by listing the calendar whole and comparing each event's stamp, so nothing is
* lost or reported twice. A calendar added to or removed from the account is
* its directory appearing or leaving, and its own details changed are its
* calendar.json, which the checkpoint keeps as it was rendered. The checkpoint
* also keeps the zone days are bucketed in: when it moves, every event's files
* may move, so each calendar is synced whole again. Mirrors Python's
* GCalDeltaHook.
*/
export class GCalDeltaHook {
accessor;
constructor(accessor) {
this.accessor = accessor;
}
async pull(root, checkpoint) {
const calendars = await calendarIndex(this.accessor);
const tz = bucketZone(this.accessor, calendars);
const prefix = mountPrefixOf(root.virtual, root.vfsPath);
const match = detectScope(root);
const wanted = match.kind === ROOT ? null : (match.slots.calendar ?? null);
const decoded = decode(checkpoint);
const [zone, previous, shown] = decoded ?? [null, null, new Map()];
const states = new Map();
const details = new Map();
const changes = [];
for (const [name, entry] of [...calendars].sort(([a], [b]) => compareCodePoints(a, b))) {
const calId = entry.id;
if ((wanted !== null && name !== wanted) || typeof calId !== 'string')
continue;
const base = `${prefix}/${name}`;
const freeBusy = entry.accessRole === FREE_BUSY_ROLE;
let last = previous?.get(name) ?? null;
const shownNow = new TextDecoder().decode(calendarPayload(entry, tz));
details.set(name, shownNow);
if (previous !== null && last === null)
changes.push([FileChangeKind.CREATE, base]);
else if (last !== null && shown.get(name) !== shownNow) {
changes.push([FileChangeKind.UPDATE, `${base}/${CALENDAR_FILE}`]);
}
if (last !== null && zone !== tz)
last = [null, last[1]];
states.set(name, await this.sync(calId, base, tz, freeBusy, last, last !== null ? changes : []));
}
if (previous === null) {
return new Delta({ changes: [], checkpoint: encode(tz, states, details) });
}
for (const name of [...previous.keys()].sort(compareCodePoints)) {
if (!states.has(name) && (wanted === null || name === wanted)) {
changes.push([FileChangeKind.DELETE, `${prefix}/${name}`]);
}
}
return new Delta({
changes: changesUnder(root, changes, new Date()),
checkpoint: encode(tz, states, details),
});
}
/**
* One calendar's events since its last sync, recorded as changes to its
* files, and its state after them. Mirrors Python's `_sync`.
*/
async sync(calId, base, tz, freeBusy, last, changes) {
const calendar = this.accessor.calendar;
const token = last?.[0] ?? null;
const known = new Map(last?.[1] ?? []);
let whole = token === null;
let events;
let next;
try {
;
[events, next] = await calendar.syncEvents(calId, token, tz);
}
catch (err) {
if (err.status !== 410)
throw err;
console.debug(`gcal sync token for ${calId} expired: ${String(err)}`);
whole = true;
[events, next] = await calendar.syncEvents(calId, null, tz);
}
const seen = new Set();
for (const event of events) {
const eventId = textField(event, 'id');
if (eventId === null)
continue;
seen.add(eventId);
const [before, stamp] = known.get(eventId) ?? [[], null];
const after = event.status === 'cancelled' ? [] : eventPaths(this.accessor, base, event, tz, freeBusy);
const updated = textField(event, 'updated');
const same = known.has(eventId) && stamp === updated;
for (const item of changesBetween(before, after, () => !same))
changes.push(item);
if (after.length > 0)
known.set(eventId, [after, updated]);
else
known.delete(eventId);
}
if (whole) {
for (const eventId of [...known.keys()].sort(compareCodePoints)) {
if (seen.has(eventId))
continue;
for (const item of changesBetween(known.get(eventId)?.[0] ?? [], [], () => false))
changes.push(item);
known.delete(eventId);
}
}
return [next, known];
}
}
function encode(zone, states, details) {
const calendars = {};
for (const [name, [token, known]] of states) {
const events = {};
for (const [eventId, [paths, updated]] of known)
events[eventId] = { paths, updated };
calendars[name] = { details: details.get(name) ?? '', events, token };
}
return sortedJson({ gcal: { calendars, zone } });
}
/**
* The bucket zone, each calendar's state and each calendar's calendar.json a
* checkpoint holds, null for a baseline. Mirrors Python's `_decode`.
*/
function decode(checkpoint) {
if (checkpoint === null)
return null;
const data = JSON.parse(checkpoint);
const state = data.gcal;
if (state === undefined)
return null;
const out = new Map();
const details = new Map();
for (const [name, raw] of Object.entries((state.calendars ?? {}))) {
const row = raw;
const known = new Map();
for (const [eventId, event] of Object.entries(row.events ?? {})) {
const paths = Array.isArray(event.paths) ? event.paths.map(String) : [];
known.set(eventId, [paths, typeof event.updated === 'string' ? event.updated : null]);
}
details.set(name, typeof row.details === 'string' ? row.details : '');
out.set(name, [typeof row.token === 'string' ? row.token : null, known]);
}
return [typeof state.zone === 'string' ? state.zone : null, out, details];
}
/** Build the Calendar delta hook (`GCalDeltaHook`). Mirrors Python's `build_delta_hook`. */
export function buildDeltaHook(accessor) {
return new GCalDeltaHook(accessor);
}
//# sourceMappingURL=events.js.map