@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
304 lines • 13.7 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 { FileChangeKind } from "../../types.js";
import { movedBelow } from "../../events/delta.js";
import { eventAt, field, textField } from "../../events/payload.js";
import { TrelloApiError, getBoard, getCard, getList, listWorkspaces } from "./client.js";
import { boardDirname, cardDirname, listDirname, workspaceDirname } from "./pathing.js";
const CARD_FILES = ['card.json', 'comments.jsonl'];
const CARD_MADE = new Set([
'createCard',
'copyCard',
'convertToCardFromCheckItem',
'moveCardToBoard',
]);
const CARD_GONE = new Set(['deleteCard', 'moveCardFromBoard']);
const CARD_EDITS = new Set([
'addLabelToCard',
'removeLabelFromCard',
'addMemberToCard',
'removeMemberFromCard',
]);
const COMMENT_EDITS = new Set([
'commentCard',
'updateComment',
'deleteComment',
]);
const LABEL_EDITS = new Set(['createLabel', 'updateLabel', 'deleteLabel']);
const MEMBER_EDITS = new Set(['addMemberToBoard', 'removeMemberFromBoard']);
/**
* An entity's id and name as the dirname helpers read them.
*
* Mirrors Python `_named` (`core/trello/events.py`).
*/
function named(entity, name = null) {
return {
id: textField(entity, 'id') ?? '',
name: (name ?? '') || (textField(entity, 'name') ?? ''),
};
}
/**
* Map one Trello webhook delivery onto mount paths.
*
* The consumer owns the callback URL and passes the action's `type` with the
* whole delivery body (`action` and `model`). Nothing here runs a loop.
*
* Every directory is `<name>__<id>`, and an action names the board, list and
* card it touched with both, so a path is built from the action alone; only
* the workspace a board sits in is read, once per board, from the delivery's
* board `model` or `/boards/<id>`. A board outside the mount's `workspaceId`
* or `boardIds` maps to nothing.
*
* A card is the directory `.../lists/<list>/cards/<card>` with `card.json` and
* `comments.jsonl`. A card made or moved in creates both, and one deleted,
* archived or moved to another board deletes its directory; a deleted card
* names no name, so where the hook saw it last is used, and a card it never
* saw makes its list's `cards` UNKNOWN. An action on a card that does not name
* its list (a label or a member added) reads the card and its list once, and
* one Trello no longer has makes the board UNKNOWN. A card renamed or moved
* to another list moves both files. A comment updates `comments.jsonl`, and a
* change to what `card.json` renders (its fields, labels or members) updates
* that.
*
* A list or a board renamed moves everything below it, which no action lists,
* so the old and new directory are UNKNOWN, and the cards the hook remembers
* below it move with it; one archived is deleted, and one restored is UNKNOWN.
* A board moved out of the mount's workspace is deleted where it was. Any
* other change to a list or a board updates its `list.json` or `board.json`. A
* label or a board member added, changed or removed is UNKNOWN on `labels` or
* `members`, since the file names come from what changed.
*
* Mirrors Python `TrelloEventHook` (`core/trello/events.py`).
*/
export class TrelloEventHook {
accessor;
workspaces = new Map();
orgs = new Map();
cards = Object.create(null);
constructor(accessor) {
this.accessor = accessor;
}
async workspaceDir(workspaceId) {
if (!this.workspaces.has(workspaceId)) {
for (const listed of await listWorkspaces(this.accessor.transport)) {
this.workspaces.set(String(listed.id), workspaceDirname(listed));
}
}
return this.workspaces.get(workspaceId);
}
async org(boardId, model) {
if (textField(model, 'id') === boardId) {
this.orgs.set(boardId, textField(model, 'idOrganization'));
}
if (!this.orgs.has(boardId)) {
const board = await getBoard(this.accessor.transport, boardId);
this.orgs.set(boardId, textField(board, 'idOrganization'));
}
return this.orgs.get(boardId) ?? null;
}
async boardDir(board, model, name = null, org = null) {
const boardId = textField(board, 'id');
if (boardId === null)
return null;
const { boardIds, workspaceId: only } = this.accessor;
if (boardIds !== null && boardIds.length > 0 && !boardIds.includes(boardId))
return null;
const workspaceId = org ?? (await this.org(boardId, model));
if (workspaceId === null || (only !== null && only !== '' && workspaceId !== only)) {
return null;
}
const workspace = await this.workspaceDir(workspaceId);
if (workspace === undefined)
return null;
return `workspaces/${workspace}/boards/${boardDirname(named(board, name))}`;
}
cardDir(boardDir, card, lst, name = null) {
const cardId = textField(card, 'id');
if (cardId === null)
return null;
if (textField(lst, 'id') === null || !((name ?? '') || textField(card, 'name'))) {
return this.cards[cardId] ?? null;
}
const place = `${boardDir}/lists/${listDirname(named(lst))}/cards/` + cardDirname(named(card, name));
this.cards[cardId] = place;
return place;
}
// Where a card an action names without its list is, read from the card and
// its list, or null when Trello no longer has either, as for a delivery
// that arrives after the card was deleted.
async located(boardDir, cardId) {
try {
const found = (await getCard(this.accessor.transport, cardId));
const listId = textField(found, 'idList');
if (listId === null)
return null;
const lst = (await getList(this.accessor.transport, listId));
return this.cardDir(boardDir, found, lst);
}
catch (err) {
if (!(err instanceof TrelloApiError) || err.status !== 404)
throw err;
console.debug(`trello events: card ${cardId} is gone: ${err.message}`);
return null;
}
}
files(root, place, kind) {
return CARD_FILES.map((name) => eventAt(root, `${place}/${name}`, kind));
}
async updateCard(root, boardDir, data) {
const card = field(data, 'card');
const old = field(data, 'old');
const lst = field(data, 'list');
const was = this.cardDir(boardDir, card, field(data, 'listBefore') ?? lst, textField(old, 'name'));
let now = this.cardDir(boardDir, card, field(data, 'listAfter') ?? lst);
const cardId = textField(card, 'id');
if (now === null && cardId !== null) {
now = await this.located(boardDir, cardId);
if (now === null)
return [eventAt(root, boardDir, FileChangeKind.UNKNOWN)];
}
if (now === null)
return [];
if (field(old, 'closed') !== null) {
if (field(card, 'closed') === true) {
Reflect.deleteProperty(this.cards, textField(card, 'id') ?? '');
return [eventAt(root, now, FileChangeKind.DELETE)];
}
return this.files(root, now, FileChangeKind.CREATE);
}
if (was !== null && was !== now) {
return CARD_FILES.map((name) => eventAt(root, `${now}/${name}`, FileChangeKind.MOVE, `${was}/${name}`));
}
return [eventAt(root, `${now}/card.json`, FileChangeKind.UPDATE)];
}
async card(root, kind, boardDir, data) {
if (kind === 'updateCard')
return this.updateCard(root, boardDir, data);
const card = field(data, 'card');
const lst = field(data, 'list');
let place = this.cardDir(boardDir, card, lst);
if (CARD_GONE.has(kind)) {
Reflect.deleteProperty(this.cards, textField(card, 'id') ?? '');
if (place !== null)
return [eventAt(root, place, FileChangeKind.DELETE)];
if (textField(lst, 'id') === null)
return [];
const cards = `${boardDir}/lists/${listDirname(named(lst))}/cards`;
return [eventAt(root, cards, FileChangeKind.UNKNOWN)];
}
const cardId = textField(card, 'id');
if (place === null && cardId !== null) {
place = await this.located(boardDir, cardId);
if (place === null)
return [eventAt(root, boardDir, FileChangeKind.UNKNOWN)];
}
if (place === null)
return [];
if (CARD_MADE.has(kind))
return this.files(root, place, FileChangeKind.CREATE);
const name = COMMENT_EDITS.has(kind) ? 'comments.jsonl' : 'card.json';
return [eventAt(root, `${place}/${name}`, FileChangeKind.UPDATE)];
}
list(root, kind, boardDir, data) {
const lst = field(data, 'list');
if (textField(lst, 'id') === null)
return [];
const lists = `${boardDir}/lists`;
const now = `${lists}/${listDirname(named(lst))}`;
const old = field(data, 'old');
const closedChanged = field(old, 'closed') !== null;
if (kind === 'createList')
return [eventAt(root, `${now}/list.json`, FileChangeKind.CREATE)];
if (kind === 'moveListFromBoard' || (closedChanged && field(lst, 'closed') === true)) {
movedBelow(this.cards, new Map([[now, null]]), new Set());
return [eventAt(root, now, FileChangeKind.DELETE)];
}
const oldName = textField(old, 'name');
if (oldName !== null) {
const was = `${lists}/${listDirname(named(lst, oldName))}`;
movedBelow(this.cards, new Map([[was, now]]), new Set());
return [
eventAt(root, was, FileChangeKind.UNKNOWN),
eventAt(root, now, FileChangeKind.UNKNOWN),
];
}
if (kind === 'moveListToBoard' || closedChanged) {
return [eventAt(root, now, FileChangeKind.UNKNOWN)];
}
return [eventAt(root, `${now}/list.json`, FileChangeKind.UPDATE)];
}
async board(root, boardDir, data, model) {
const board = field(data, 'board');
const old = field(data, 'old');
if (field(old, 'closed') !== null) {
if (field(board, 'closed') !== true)
return [eventAt(root, boardDir, FileChangeKind.UNKNOWN)];
movedBelow(this.cards, new Map([[boardDir, null]]), new Set());
return [eventAt(root, boardDir, FileChangeKind.DELETE)];
}
const oldName = textField(old, 'name');
const oldOrg = textField(old, 'idOrganization');
if (oldName === null && oldOrg === null) {
return [eventAt(root, `${boardDir}/board.json`, FileChangeKind.UPDATE)];
}
const events = [eventAt(root, boardDir, FileChangeKind.UNKNOWN)];
const was = await this.boardDir(board, model, oldName, oldOrg);
if (was !== null && was !== boardDir) {
movedBelow(this.cards, new Map([[was, boardDir]]), new Set());
events.unshift(eventAt(root, was, FileChangeKind.UNKNOWN));
}
return events;
}
// Map an `updateBoard` that moved a board out of the workspace the mount
// shows: it is deleted where it was.
async left(root, data, model) {
const old = field(data, 'old');
const oldOrg = textField(old, 'idOrganization');
if (oldOrg === null)
return [];
const was = await this.boardDir(field(data, 'board'), model, textField(old, 'name'), oldOrg);
if (was === null)
return [];
movedBelow(this.cards, new Map([[was, null]]), new Set());
return [eventAt(root, was, FileChangeKind.DELETE)];
}
async toEvents(root, eventType, payload) {
const data = field(field(payload, 'action'), 'data');
const model = field(payload, 'model');
const boardDir = await this.boardDir(field(data, 'board'), model);
if (boardDir === null) {
return eventType === 'updateBoard' ? this.left(root, data, model) : [];
}
if (CARD_MADE.has(eventType) ||
CARD_GONE.has(eventType) ||
CARD_EDITS.has(eventType) ||
COMMENT_EDITS.has(eventType) ||
eventType === 'updateCard') {
return this.card(root, eventType, boardDir, data);
}
if (['createList', 'updateList', 'moveListFromBoard', 'moveListToBoard'].includes(eventType)) {
return this.list(root, eventType, boardDir, data);
}
if (eventType === 'updateBoard')
return this.board(root, boardDir, data, model);
if (LABEL_EDITS.has(eventType)) {
return [eventAt(root, `${boardDir}/labels`, FileChangeKind.UNKNOWN)];
}
if (MEMBER_EDITS.has(eventType)) {
return [eventAt(root, `${boardDir}/members`, FileChangeKind.UNKNOWN)];
}
return [];
}
}
//# sourceMappingURL=events.js.map