diff --git a/src/dict/env-dict.ts b/src/dict/env-dict.ts index bb293a8..e6aa8ba 100644 --- a/src/dict/env-dict.ts +++ b/src/dict/env-dict.ts @@ -66,6 +66,12 @@ export const EnvDict: readonly KnownEnv[] = [ required: false, secret: false, }, + { + key: "MAIN_COMPUTER_DATA_DIR", + description: `Where the main computer's home (/home/gem) is on this server's disk, for a computer the app didn't create itself (see MAIN_COMPUTER_URL), e.g. the bind mount your sandbox's compose file uses. Empty reads DATA_DIR/computers/.`, + required: false, + secret: false, + }, ...HarnessProviderKinds.flatMap((provider) => provider.key_env ? [ diff --git a/src/functions/backend/db/computers/resolve-computer-home-dir.test.ts b/src/functions/backend/db/computers/resolve-computer-home-dir.test.ts new file mode 100644 index 0000000..9e6643a --- /dev/null +++ b/src/functions/backend/db/computers/resolve-computer-home-dir.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import resolveComputerHomeDir from "./resolve-computer-home-dir"; + +const data_dir = "/data"; + +describe("resolveComputerHomeDir", () => { + test("uses the directory the app bind-mounted", () => { + /** + * The truth for a container the app provisioned: on a bind mount + * that can be anywhere on the docker host, not under DATA_DIR. + */ + expect( + resolveComputerHomeDir({ + computer_id: 2, + kind: "sandbox", + metadata: JSON.stringify({ data_dir: "/srv/homes/2", host_dir: "/host/2" }), + main_data_dir: "/wrong", + data_dir, + }), + ).toBe("/srv/homes/2"); + }); + + test("falls back to MAIN_COMPUTER_DATA_DIR for the main computer", () => { + expect( + resolveComputerHomeDir({ + computer_id: 1, + kind: "main", + metadata: null, + main_data_dir: "/media/docker/aio-sandbox/data/home", + data_dir, + }), + ).toBe("/media/docker/aio-sandbox/data/home"); + }); + + test("leaves a sandbox alone whatever the main setting says", () => { + expect( + resolveComputerHomeDir({ + computer_id: 3, + kind: "sandbox", + metadata: null, + main_data_dir: "/media/docker/aio-sandbox/data/home", + data_dir, + }), + ).toBe("/data/computers/3"); + }); + + test("uses the conventional folder when nothing is recorded", () => { + expect(resolveComputerHomeDir({ computer_id: 4, kind: "main", data_dir })).toBe( + "/data/computers/4", + ); + }); + + test("ignores an empty or unreadable record", () => { + expect( + resolveComputerHomeDir({ computer_id: 5, metadata: JSON.stringify({ data_dir: " " }), data_dir }), + ).toBe("/data/computers/5"); + expect(resolveComputerHomeDir({ computer_id: 5, metadata: "not json", data_dir })).toBe( + "/data/computers/5", + ); + expect(resolveComputerHomeDir({ computer_id: 5, metadata: null, data_dir })).toBe( + "/data/computers/5", + ); + }); + + test("normalizes a recorded relative path", () => { + expect( + resolveComputerHomeDir({ computer_id: 6, metadata: JSON.stringify({ data_dir: "/srv/a/../b" }), data_dir }), + ).toBe("/srv/b"); + }); +}); diff --git a/src/functions/backend/db/computers/resolve-computer-home-dir.ts b/src/functions/backend/db/computers/resolve-computer-home-dir.ts new file mode 100644 index 0000000..5a19ed5 --- /dev/null +++ b/src/functions/backend/db/computers/resolve-computer-home-dir.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import { AppData } from "@/src/data/app-data"; +import parseJobJson from "@/src/bots-queue/parse-job-json"; + +type Params = { + computer_id: number | string; + /** + * The computer's kind: only the install's main computer can be + * reached at `MAIN_COMPUTER_URL`, so only it uses + * `MAIN_COMPUTER_DATA_DIR`. + */ + kind?: string | null; + /** + * Its container's `metadata`, as `provisionComputer` wrote it (the + * `data_dir` it bound into the container). + */ + metadata?: string | null; + /** + * What `MAIN_COMPUTER_DATA_DIR` is set to, already read + * (`grabEnv`). + */ + main_data_dir?: string | null; + /** + * The data volume's root as this app sees it (`DATA_DIR`). + */ + data_dir: string; +}; + +/** + * Where a computer's home (`AppData.ComputerHomeDir` in its container) + * actually is on this app's disk, for the files browser and for anything + * else that has to read a computer's files. + * + * A container the app provisioned recorded the directory it bind-mounted + * (`containers.metadata.data_dir`), which is the truth: on a bind mount + * that path can be anywhere the docker host allows. A computer the app + * didn't provision has no record. That is the install's main computer on a + * server reached at `MAIN_COMPUTER_URL` (a dev server outside docker), + * whose sandbox keeps its home wherever its compose file says, so + * `MAIN_COMPUTER_DATA_DIR` names that directory for this app. Without + * either, the conventional place every app-provisioned computer uses: + * `DATA_DIR//`. + * + * Kept apart from the docker and database calls so the precedence is + * testable on its own. + */ +export default function resolveComputerHomeDir({ + computer_id, + kind, + metadata, + main_data_dir, + data_dir, +}: Params) { + const recorded = parseJobJson({ text: metadata || "" })?.data_dir; + + if (typeof recorded == "string" && recorded.trim()) return path.resolve(recorded.trim()); + + const external = kind == "main" ? main_data_dir?.trim() : ""; + + if (external) return path.resolve(external); + + return path.join(data_dir, AppData.ComputerDataDir, String(computer_id)); +} diff --git a/src/functions/backend/storage/list-storage-entries.ts b/src/functions/backend/storage/list-storage-entries.ts index 3adab30..31cc589 100644 --- a/src/functions/backend/storage/list-storage-entries.ts +++ b/src/functions/backend/storage/list-storage-entries.ts @@ -70,9 +70,11 @@ export default async function listStorageEntries({ /** * A folder nothing has written to yet (a computer's home before it * starts, a bot's before its first file) shows as empty rather than as - * an error. + * an error. `missing` keeps that apart from a path that isn't there at + * all, which a stale folder link lands on (the page says so, instead of + * claiming the folder is empty). */ - if (!existsSync(absolute)) return { entries: [], total: 0, offset }; + if (!existsSync(absolute)) return { entries: [], total: 0, offset, missing: true }; const rows = readdirSync(absolute, { withFileTypes: true }); const indexed = await botFileRows({ resolved, paths: rows.map((row) => joinPath({ relative, name: row.name })) }); @@ -145,6 +147,7 @@ export default async function listStorageEntries({ entries: entries.slice(offset, offset + AppData.StorageBrowserPageSize), total: entries.length, offset, + missing: false, }; } diff --git a/src/functions/backend/storage/resolve-storage-spec.ts b/src/functions/backend/storage/resolve-storage-spec.ts index 2825a7e..cf9e0fe 100644 --- a/src/functions/backend/storage/resolve-storage-spec.ts +++ b/src/functions/backend/storage/resolve-storage-spec.ts @@ -10,6 +10,8 @@ import { storageVolumeLabel, type StorageSpec } from "@/src/utils/storage-volume import grabCrewRole from "../db/access/grab-crew-role"; import grabUserBot from "../db/bots/grab-user-bot"; import grabUserComputer from "../db/computers/grab-user-computer"; +import resolveComputerHomeDir from "../db/computers/resolve-computer-home-dir"; +import grabEnv from "../db/envs/grab-env"; import grabUserWorkspace from "../db/workspaces/grab-user-workspace"; type Params = { @@ -69,10 +71,19 @@ async function storageRoot({ ? path.join(DATA_DIR, AppData.WorkspaceDataDir, String(spec.owner_id)) : null; - case "computers": - return (await grabUserComputer({ computer_id: spec.owner_id, user_id })) - ? path.join(DATA_DIR, AppData.ComputerDataDir, String(spec.owner_id)) - : null; + case "computers": { + const computer = await grabUserComputer({ computer_id: spec.owner_id, user_id }); + + if (!computer) return null; + + return resolveComputerHomeDir({ + computer_id: spec.owner_id, + kind: computer.kind, + metadata: await computerContainerMetadata({ container_id: computer.container_id }), + main_data_dir: await grabEnv({ key: "MAIN_COMPUTER_DATA_DIR" }), + data_dir: DATA_DIR, + }); + } case "media": /** @@ -100,6 +111,23 @@ async function storageRoot({ } } +/** + * A computer's container `metadata`, where `provisionComputer` recorded + * the directory it bind-mounted as the computer's home. Null when the app + * didn't create the container (a computer reached at + * `MAIN_COMPUTER_URL`), which is what `MAIN_COMPUTER_DATA_DIR` is for. + */ +async function computerContainerMetadata({ container_id }: { container_id?: number | "" }) { + if (!container_id) return null; + + const res = await BunSQLite.sql<{ metadata?: string | null }>({ + sql: `SELECT metadata FROM containers WHERE id = ? LIMIT 1`, + values: [container_id], + }); + + return res.payload?.[0]?.metadata || null; +} + /** * What the page calls the folder: the bot's, workspace's or computer's * own name where one is known. diff --git a/src/pages/admin/files/(functions)/folder-text.test.ts b/src/pages/admin/files/(functions)/folder-text.test.ts new file mode 100644 index 0000000..c5a3011 --- /dev/null +++ b/src/pages/admin/files/(functions)/folder-text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import type { StorageFolderPayload } from "@/src/types"; +import { emptyFolderText, folderSummary } from "./folder-text"; + +function folder(overrides: Partial = {}): StorageFolderPayload { + return { + spec: { volume: "computers", owner_id: 1 }, + label: "Main computer", + path: "seo/coderank", + breadcrumbs: [], + entries: [], + total: 0, + offset: 0, + ...overrides, + }; +} + +describe("folderSummary", () => { + test("counts what is in the folder", () => { + expect(folderSummary({ folder: folder({ total: 4 }) })).toContain("4 item(s)"); + }); + + test("says a missing folder isn't on disk, not that it is empty", () => { + const text = folderSummary({ folder: folder({ missing: true }) }); + + expect(text).toContain("isn't on disk"); + expect(text).not.toContain("0 item(s)"); + }); + + test("asks for a folder when there is none", () => { + expect(folderSummary({ folder: null })).toBe("Pick a folder to look inside."); + }); +}); + +describe("emptyFolderText", () => { + test("an empty folder says so", () => { + expect(emptyFolderText({ folder: folder(), search: "" })).toBe("This folder is empty."); + }); + + test("a path that isn't there says so instead", () => { + expect(emptyFolderText({ folder: folder({ missing: true }), search: "" })).toBe( + "There's no seo/coderank in here. It may have been moved or deleted.", + ); + }); + + test("loading, an error and a search each win over the folder's own state", () => { + expect(emptyFolderText({ folder: folder({ missing: true }), search: "", loading: true })).toBe( + "Loading...", + ); + expect( + emptyFolderText({ folder: folder({ missing: true }), search: "", error: "Not found." }), + ).toBe("Not found."); + expect(emptyFolderText({ folder: folder({ missing: true }), search: "raw" })).toBe( + 'Nothing matches "raw".', + ); + }); +}); diff --git a/src/pages/admin/files/(functions)/folder-text.ts b/src/pages/admin/files/(functions)/folder-text.ts new file mode 100644 index 0000000..75e1c0a --- /dev/null +++ b/src/pages/admin/files/(functions)/folder-text.ts @@ -0,0 +1,42 @@ +import type { StorageFolderPayload } from "@/src/types"; + +/** + * The line under the open folder's name: how much is in it, or, for a path + * that isn't on disk, that it isn't there. A stale folder link must never + * read as an empty folder. + */ +export function folderSummary({ folder }: { folder: StorageFolderPayload | null }) { + if (!folder) return "Pick a folder to look inside."; + + if (folder.missing) { + return `${folder.label}: this folder isn't on disk. Nothing has written it yet, or its link is out of date.`; + } + + return `${folder.label}: ${folder.total} item(s). Everything a bot, a workspace or a computer keeps for you.`; +} + +/** + * What an empty listing says, and why it is empty when the folder the + * user asked for isn't there at all. + */ +export function emptyFolderText({ + folder, + search, + loading, + error, +}: { + folder: StorageFolderPayload | null; + search: string; + loading?: boolean; + error?: string; +}) { + if (loading) return "Loading..."; + if (error) return error; + if (search) return `Nothing matches "${search}".`; + + if (folder?.missing) { + return `There's no ${folder.path || "folder"} in here. It may have been moved or deleted.`; + } + + return "This folder is empty."; +} diff --git a/src/pages/admin/files/(sections)/files-browser.tsx b/src/pages/admin/files/(sections)/files-browser.tsx index 91ba96e..8b9f2f5 100644 --- a/src/pages/admin/files/(sections)/files-browser.tsx +++ b/src/pages/admin/files/(sections)/files-browser.tsx @@ -4,6 +4,7 @@ import Span from "@moduletrace/twui/layout/Span"; import Stack from "@moduletrace/twui/layout/Stack"; import type { StorageFolderPayload, StorageSpecOption } from "@/src/types"; import type { StorageSpec } from "@/src/utils/storage-volumes"; +import { emptyFolderText, folderSummary } from "../(functions)/folder-text"; import FilesBreadcrumbRow from "../(partials)/files-breadcrumb-row"; import FilesSearchInput from "../(partials)/files-search-input"; import FilesVolumeRow from "../(partials)/files-volume-row"; @@ -81,9 +82,7 @@ export default function FilesBrowser({ "Files"} - {folder - ? `${folder.label}: ${folder.total} item(s). Everything a bot, a workspace or a computer keeps for you.` - : "Pick a folder to look inside."} + {folderSummary({ folder })} - {loading - ? "Loading..." - : error || - (search - ? `Nothing matches "${search}".` - : "This folder is empty.")} + {emptyFolderText({ folder, search, loading, error })} )} diff --git a/src/pages/admin/files/index.server.ts b/src/pages/admin/files/index.server.ts index 7815c2e..91a7898 100644 --- a/src/pages/admin/files/index.server.ts +++ b/src/pages/admin/files/index.server.ts @@ -80,5 +80,6 @@ async function folderPayload({ entries: listing.entries, total: listing.total, offset: listing.offset, + missing: listing.missing, }; } diff --git a/src/pages/api/admin/storage/list.ts b/src/pages/api/admin/storage/list.ts index a4286be..ccdd5bb 100644 --- a/src/pages/api/admin/storage/list.ts +++ b/src/pages/api/admin/storage/list.ts @@ -61,6 +61,7 @@ export const handler: BunextAPIRouteHandler { `/admin/files?volume=computers&owner=3&path=seo%2Fraw`, ); }); + + test("keeps the folder when given the browser's own volume name", () => { + /** + * What the chip does: it holds a parsed spec, whose `volume` is the + * browser's (`computers`), not the ref's (`computer`). Losing that + * dropped the whole query and landed on the files browser's first + * folder. + */ + expect(folderRefUrl({ volume: "computers", owner_id: 1, path: "seo/coderank" })).toBe( + `/admin/files?volume=computers&owner=1&path=seo%2Fcoderank`, + ); + expect(folderRefUrl({ volume: "bots", owner_id: 6, path: "reports" })).toBe( + `/admin/files?volume=bots&owner=6&path=reports`, + ); + }); + + test("falls back to the files browser for a volume it doesn't know", () => { + expect(folderRefUrl({ volume: "secrets", owner_id: 1, path: "a" })).toBe("/admin/files"); + expect(folderRefUrl({ volume: "computers", owner_id: 0 })).toBe("/admin/files"); + }); }); describe("linkComputerRefs", () => { @@ -68,6 +88,16 @@ describe("linkComputerRefs", () => { }); }); + test("a linked folder ref still opens on that folder", () => { + const linked = linkComputerRefs({ text: "straight off the machine: [folder:computer:1:seo/coderank]" }); + const ref = parseFolderRefHref({ href: linked.match(/\((#folder-[^)]+)\)/)?.[1] }); + + expect(ref).not.toBeNull(); + expect( + folderRefUrl({ volume: ref!.spec.volume, owner_id: ref!.spec.owner_id, path: ref!.path }), + ).toBe(`/admin/files?volume=computers&owner=1&path=seo%2Fcoderank`); + }); + test("leaves a file ref to the file chip", () => { const linked = linkComputerRefs({ text: "The report: [file:12]." }); diff --git a/src/utils/folder-ref.ts b/src/utils/folder-ref.ts index 0e81fb7..c08b4b6 100644 --- a/src/utils/folder-ref.ts +++ b/src/utils/folder-ref.ts @@ -10,6 +10,8 @@ * name's extension: the ref says which one it is. */ import { + isStorageVolume, + parseStorageSpec, StorageBrowserPath, storageSpecHref, storageVolumeFromRef, @@ -96,11 +98,21 @@ export function parseFolderRefHref({ /** * Where a folder ref opens: the files browser, on that folder. A volume the * browser doesn't know lands on its first folder. + * + * Both spellings of a volume come through here: a ref names it the way a + * bot writes it (`computer`), the files browser the way it stores it + * (`computers`). Either one resolves, so a chip never loses its folder and + * drops back to the files browser's first folder. */ export function folderRefUrl({ volume, owner_id, path }: RefParams) { - const known = storageVolumeFromRef({ name: volume }); + const from_ref = storageVolumeFromRef({ name: volume }); + const known = from_ref || (isStorageVolume(volume) ? volume : null); if (!known) return StorageBrowserPath; - return storageSpecHref({ spec: { volume: known, owner_id: Number(owner_id) }, path }); + const spec = parseStorageSpec({ volume: known, owner_id }); + + if (!spec) return StorageBrowserPath; + + return storageSpecHref({ spec, path }); }