UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

304 lines • 13.7 kB
// ========= 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