Open a folder chip on its folder, in the folder that really holds it
A bot's folder chip went to bare /admin/files instead of the folder it named, and even once there the computer volume pointed at the wrong directory, so the folder a bot linked to looked empty. The chip lost its folder because folderRefUrl only accepted a ref's volume name (computer) while the chip hands it the browser's (computers), so storageVolumeFromRef found nothing and the whole query was dropped. Both spellings resolve now, through parseStorageSpec, so a bad owner falls back cleanly instead of emitting owner=0. The deeper bug: a computer's home was assumed to be DATA_DIR/computers/<id>, but on a bind mount it can be anywhere on the docker host, and a computer the app didn't provision (the main one on a server reached at MAIN_COMPUTER_URL, i.e. a dev server outside docker) records nothing at all. resolveComputerHomeDir holds the precedence: the directory provisionComputer actually bound in (containers.metadata.data_dir), else the new MAIN_COMPUTER_DATA_DIR app setting for the main computer, else the conventional folder. A path that isn't on disk also no longer reads as an empty folder: listStorageEntries reports it as missing and the page says so, so a stale folder link can't look like a folder nothing has written yet.
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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/<AppData.ComputerDataDir>/<id>`.
|
||||
*
|
||||
* 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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user