@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
186 lines • 8.68 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 { ContentType, FileStat, FileType } from "../../types.js";
import { unsearchedText } from "../../utils/record_search.js";
import { stringOf as pickString } from "../../utils/records.js";
import { makeReaddir } from "../hierarchy/readdir.js";
import { entryStat } from "../hierarchy/stat.js";
import { jsonBytes } from "../render/json.js";
import { getBoard, listBoardLabels, listBoardLists, listBoardMembers, listListCards, listWorkspaceBoards, listWorkspaces, } from "./client.js";
import { normalizeBoard, normalizeCard, normalizeLabel, normalizeList, normalizeMember, normalizeWorkspace, SEARCHED_FIELDS, } from "./normalize.js";
import { boardDirname, cardDirname, labelFilename, listDirname, memberFilename, workspaceDirname, } from "./pathing.js";
import { detectScope } from "./scope.js";
function extraSize(entry) {
const value = entry.extra.json_size;
return typeof value === 'number' ? value : null;
}
/** The workspaces the mount shows: the member's, narrowed to `workspaceId` when it is set. */
export async function filteredWorkspaces(accessor) {
let workspaces = await listWorkspaces(accessor.transport);
if (accessor.workspaceId !== null && accessor.workspaceId !== '') {
workspaces = workspaces.filter((w) => pickString(w, 'id') === accessor.workspaceId);
}
return workspaces;
}
/** A workspace's open boards the mount shows, narrowed to `boardIds` when it is set. */
export async function filteredBoards(accessor, workspaceId) {
let boards = await listWorkspaceBoards(accessor.transport, workspaceId);
if (accessor.boardIds !== null && accessor.boardIds.length > 0) {
const allowed = new Set(accessor.boardIds);
boards = boards.filter((b) => allowed.has(pickString(b, 'id')));
}
return boards;
}
/** Whether `workspaceId` or `boardIds` narrows the mount. */
export function scopeIsNarrowed(accessor) {
const byWorkspace = accessor.workspaceId !== null && accessor.workspaceId !== '';
const byBoard = accessor.boardIds !== null && accessor.boardIds.length > 0;
return byWorkspace || byBoard;
}
/**
* Whether the mount's scope admits a board addressed by id.
*
* The two knobs that narrow the listing narrow an id too: a board
* `boardIds` leaves out, or one outside `workspaceId`, is not this
* mount's to read or write. Unnarrowed, every id is admitted without a
* call; `workspaceId` costs one board fetch. Mirrors python's
* `board_in_scope`.
*/
export async function boardInScope(accessor, boardId) {
if (!scopeIsNarrowed(accessor))
return true;
if (boardId === '')
return false;
if (accessor.boardIds !== null && accessor.boardIds.length > 0) {
if (!accessor.boardIds.includes(boardId))
return false;
}
if (accessor.workspaceId === null || accessor.workspaceId === '')
return true;
const board = await getBoard(accessor.transport, boardId);
return pickString(board, 'idOrganization') === accessor.workspaceId;
}
export const WORKSPACE = entryStat('workspace_id', FileType.DIRECTORY);
export const WORKSPACE_JSON = entryStat('workspace_id', ContentType.JSON);
export const BOARD = entryStat('board_id', FileType.DIRECTORY);
export const BOARD_JSON = entryStat('board_id', ContentType.JSON);
export const MEMBER = entryStat('member_id', ContentType.JSON);
export const LABEL = entryStat('label_id', ContentType.JSON);
export const LIST = entryStat('list_id', FileType.DIRECTORY);
export const LIST_JSON = entryStat('list_id', ContentType.JSON);
export const CARD = entryStat('card_id', FileType.DIRECTORY);
export const CARD_JSON = entryStat('card_id', ContentType.JSON);
export const COMMENTS = entryStat('card_id', ContentType.TEXT);
function dir(name) {
return new FileStat({ name, type: FileType.DIRECTORY });
}
async function listWorkspacesDir(accessor, _match) {
// workspace.json renders the workspace object this listing already
// fetched, so its exact size rides the directory's row for the child
// listing to read back without another call.
return (await filteredWorkspaces(accessor)).map((workspace) => WORKSPACE(workspaceDirname(workspace), pickString(workspace, 'id'), {
extra: { json_size: jsonBytes(normalizeWorkspace(workspace)).byteLength },
}));
}
function listWorkspace(_accessor, match, entry) {
return Promise.resolve([
WORKSPACE_JSON('workspace.json', match.slots.workspace_id ?? '', { size: extraSize(entry) }),
dir('boards'),
]);
}
async function listBoards(accessor, match, _entry) {
// board.json's normalizer only uses fields the board listing already
// carries, so its exact size is free here.
return (await filteredBoards(accessor, match.slots.workspace_id ?? '')).map((board) => BOARD(boardDirname(board), pickString(board, 'id'), {
modified: pickString(board, 'dateLastActivity'),
extra: { json_size: jsonBytes(normalizeBoard(board)).byteLength },
}));
}
function listBoard(_accessor, match, entry) {
return Promise.resolve([
BOARD_JSON('board.json', match.slots.board_id ?? '', {
size: extraSize(entry),
modified: entry.modified,
}),
dir('members'),
dir('labels'),
dir('lists'),
]);
}
async function listMembers(accessor, match, _entry) {
const members = await listBoardMembers(accessor.transport, match.slots.board_id ?? '');
return members.map((member) => MEMBER(memberFilename(member), pickString(member, 'id'), {
size: jsonBytes(normalizeMember(member)).byteLength,
}));
}
async function listLabels(accessor, match, _entry) {
const labels = await listBoardLabels(accessor.transport, match.slots.board_id ?? '');
return labels.map((label) => LABEL(labelFilename(label), pickString(label, 'id'), {
size: jsonBytes(normalizeLabel(label)).byteLength,
}));
}
async function listLists(accessor, match, _entry) {
const lists = await listBoardLists(accessor.transport, match.slots.board_id ?? '');
// list.json's normalizer only uses fields the list listing already
// carries, so its exact size is free here.
return lists.map((lst) => LIST(listDirname(lst), pickString(lst, 'id'), {
extra: { json_size: jsonBytes(normalizeList(lst)).byteLength },
}));
}
function listList(_accessor, match, entry) {
return Promise.resolve([
LIST_JSON('list.json', match.slots.list_id ?? '', { size: extraSize(entry) }),
dir('cards'),
]);
}
async function listCards(accessor, match, _entry) {
const cards = await listListCards(accessor.transport, match.slots.list_id ?? '');
// card.json's normalizer only uses fields the card listing already
// carries, so its exact size is free here.
return cards.map((card) => CARD(cardDirname(card), pickString(card, 'id'), {
modified: pickString(card, 'dateLastActivity'),
extra: {
json_size: jsonBytes(normalizeCard(card)).byteLength,
...(accessor.capped()
? { unsearched: unsearchedText(Object.entries(normalizeCard(card)), SEARCHED_FIELDS) }
: {}),
},
}));
}
function listCard(_accessor, match, entry) {
// comments.jsonl needs a per-card actions call and stays size-unknown.
const cardId = match.slots.card_id ?? '';
return Promise.resolve([
CARD_JSON('card.json', cardId, { size: extraSize(entry), modified: entry.modified }),
COMMENTS('comments.jsonl', cardId, { modified: entry.modified }),
]);
}
export const readdir = makeReaddir(detectScope, {
listers: {
workspaces: listWorkspacesDir,
},
entryListers: {
workspace: listWorkspace,
boards: listBoards,
board: listBoard,
members: listMembers,
labels: listLabels,
lists: listLists,
list: listList,
cards: listCards,
card: listCard,
},
staticRoot: ['workspaces'],
});
//# sourceMappingURL=readdir.js.map