fix(artifacts): remove thread workspace adoption
This commit is contained in:
parent
c8f379847b
commit
260380531b
16
README.md
16
README.md
@ -145,19 +145,9 @@ copying them into that task scope before returning the manifest. This covers
|
||||
agents that save output as `./file.md` while still keeping XWorkmate sync scoped
|
||||
to `tasks/<session>/<run>`.
|
||||
|
||||
When `sinceUnixMs` is provided and the prepared task scope plus current-run
|
||||
workspace-root adoption are both empty, export falls back to explicit delivery
|
||||
files in the same OpenClaw thread workspace for the supplied `sessionKey`. This
|
||||
handles long-running agents that finish in OpenClaw's owner/thread workspace
|
||||
instead of the prepared `tasks/<session>/<run>` directory. The fallback only
|
||||
copies known deliverables such as `DELIVERY.md`, `ffprobe.json`,
|
||||
`video.config.json`, `index.html`, and files under delivery directories like
|
||||
`renders/`; it does not scan other threads or borrow artifacts from earlier
|
||||
task scopes.
|
||||
|
||||
Without `sinceUnixMs`, export/list only reads the current task scope. The plugin
|
||||
never scans `tasks/` as a fallback and does not borrow artifacts from earlier
|
||||
task scopes.
|
||||
never scans `tasks/`, `owners/*/threads/*`, or any previous thread workspace as
|
||||
a fallback and does not borrow artifacts from earlier task scopes.
|
||||
|
||||
Each exported artifact includes `artifactRef`, a plugin-signed reference over
|
||||
the issued session/run scope, artifact scope, path, size, and SHA-256 digest. `read` accepts
|
||||
@ -217,7 +207,7 @@ only remote file access path.
|
||||
- Only files inside the resolved OpenClaw workspace are exported.
|
||||
- `.git`, `.openclaw`, `.xworkmate`, `.pi`, build outputs, and dependency folders are excluded from task artifact exports.
|
||||
- Workspace-root files are adopted only when `sinceUnixMs` is provided; adopted files are copied into the current `tasks/<safe-session-key>/<safe-run-id>` scope before listing or reading.
|
||||
- If `sinceUnixMs` is provided and task-scope plus workspace-root adoption are empty, export may adopt explicit deliverables from the same `sessionKey` owner/thread workspace. This fallback is limited to known delivery files and delivery directories, and never scans other thread workspaces.
|
||||
- Export never adopts files from OpenClaw owner/thread workspaces; agents must write into the prepared task scope or into the current-run workspace root for timestamp-gated adoption.
|
||||
- Symlinks are skipped to avoid workspace escape.
|
||||
- Files larger than `maxInlineBytes` are listed with metadata and a warning, but are not inlined.
|
||||
- `artifactScope` must be `tasks/<safe-session-key>/<safe-run-id>`.
|
||||
|
||||
191
dist/src/exportArtifacts.js
vendored
191
dist/src/exportArtifacts.js
vendored
@ -18,39 +18,6 @@ const SKIPPED_DIRS = new Set([
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
const THREAD_DELIVERY_FILE_NAMES = new Set([
|
||||
"DELIVERY.md",
|
||||
"delivery.md",
|
||||
"ffprobe.json",
|
||||
"video.config.json",
|
||||
"index.html",
|
||||
]);
|
||||
const THREAD_DELIVERY_DIRS = new Set([
|
||||
"assets",
|
||||
"renders",
|
||||
"render",
|
||||
"exports",
|
||||
"output",
|
||||
"outputs",
|
||||
]);
|
||||
const THREAD_DELIVERY_EXTENSIONS = new Set([
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".pdf",
|
||||
".docx",
|
||||
".pptx",
|
||||
".xlsx",
|
||||
".md",
|
||||
".html",
|
||||
".json",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".svg",
|
||||
]);
|
||||
export async function prepareXWorkmateArtifacts(input) {
|
||||
const params = input.params ?? {};
|
||||
const pluginConfig = input.pluginConfig ?? {};
|
||||
@ -133,17 +100,7 @@ export async function exportXWorkmateArtifacts(input) {
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const threadDeliveryCandidates = sinceUnixMs > 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0
|
||||
? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({
|
||||
workspaceRoot,
|
||||
scopeRoot,
|
||||
artifactScope,
|
||||
sessionKey,
|
||||
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
||||
if (!scopePrepared && candidates.length === 0) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
@ -379,152 +336,6 @@ async function adoptWorkspaceRootCandidatesIntoScope(input) {
|
||||
}
|
||||
return adopted;
|
||||
}
|
||||
async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input) {
|
||||
const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey);
|
||||
const adopted = [];
|
||||
for (const threadRoot of threadRoots) {
|
||||
const candidates = await collectThreadDeliveryCandidates({
|
||||
scanRoot: threadRoot,
|
||||
relativeRoot: threadRoot,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (input.existingRelativePaths.has(candidate.relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
|
||||
if (!isWithinRoot(input.scopeRoot, targetPath)) {
|
||||
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
|
||||
continue;
|
||||
}
|
||||
if (await fileExists(targetPath)) {
|
||||
input.existingRelativePaths.add(candidate.relativePath);
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.copyFile(candidate.absolutePath, targetPath);
|
||||
const stat = await fs.stat(targetPath);
|
||||
const realPath = await fs.realpath(targetPath);
|
||||
adopted.push({
|
||||
absolutePath: realPath,
|
||||
relativePath: candidate.relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: candidate.mtimeMs,
|
||||
artifactScope: input.artifactScope,
|
||||
scopeKind: "task",
|
||||
});
|
||||
input.existingRelativePaths.add(candidate.relativePath);
|
||||
}
|
||||
}
|
||||
return adopted;
|
||||
}
|
||||
async function resolveCurrentThreadWorkspaceRoots(workspaceRoot, sessionKey) {
|
||||
const roots = new Set();
|
||||
const realWorkspaceRoot = await fs.realpath(workspaceRoot);
|
||||
if (path.basename(realWorkspaceRoot) === sessionKey) {
|
||||
roots.add(realWorkspaceRoot);
|
||||
}
|
||||
const ownerRoots = [
|
||||
path.join(realWorkspaceRoot, "owners", "local", "user"),
|
||||
path.join(realWorkspaceRoot, "owners", "remote", "user"),
|
||||
];
|
||||
for (const ownerRoot of ownerRoots) {
|
||||
if (!(await directoryExists(ownerRoot))) {
|
||||
continue;
|
||||
}
|
||||
let ownerEntries;
|
||||
try {
|
||||
ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true });
|
||||
}
|
||||
catch {
|
||||
continue;
|
||||
}
|
||||
for (const ownerEntry of ownerEntries) {
|
||||
if (!ownerEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey);
|
||||
if (!(await directoryExists(candidate))) {
|
||||
continue;
|
||||
}
|
||||
const realCandidate = await fs.realpath(candidate);
|
||||
if (isWithinRoot(realWorkspaceRoot, realCandidate)) {
|
||||
roots.add(realCandidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
async function collectThreadDeliveryCandidates(input) {
|
||||
const candidates = [];
|
||||
await walk(input.scanRoot, []);
|
||||
return candidates;
|
||||
async function walk(currentDir, segments) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
}
|
||||
catch (error) {
|
||||
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
const relativeEntryPath = [...segments, entry.name].join("/");
|
||||
if (entry.isSymbolicLink()) {
|
||||
const isDeliveryPath = isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name);
|
||||
if (isDeliveryPath) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (SKIPPED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(absolutePath, [...segments, entry.name]);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = safeRelativePath(input.relativeRoot, absolutePath);
|
||||
if (!relativePath || !isThreadDeliveryFile(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(input.relativeRoot, realPath)) {
|
||||
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
absolutePath: realPath,
|
||||
relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function isThreadDeliveryFile(relativePath) {
|
||||
const parts = relativePath.split("/");
|
||||
const fileName = parts[parts.length - 1] ?? "";
|
||||
if (parts.length === 1) {
|
||||
return THREAD_DELIVERY_FILE_NAMES.has(fileName);
|
||||
}
|
||||
if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
async function collectCandidates(input) {
|
||||
const candidates = [];
|
||||
await walk(input.scanRoot);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-multi-session-plugins",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.13",
|
||||
"description": "OpenClaw multi-session plugin runtime support for scoped XWorkmate artifacts",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@ -389,7 +389,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("adopts same-thread delivery files when the prepared task scope is empty", async () => {
|
||||
it("does not adopt same-thread delivery files when the prepared task scope is empty", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "tmp-openclaw-multi-session-plugins-"));
|
||||
await prepareXWorkmateArtifacts({
|
||||
params: {
|
||||
@ -428,20 +428,12 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
});
|
||||
|
||||
expect(result.artifactScope).toBe("tasks/draft_1779524982823421-3/turn-1779685283403237342");
|
||||
expect(result.artifacts.map((entry) => entry.relativePath).sort()).toEqual([
|
||||
"DELIVERY.md",
|
||||
"assets/images/manifest.md",
|
||||
"assets/images/security-identity-evolution/001-local-permission.png",
|
||||
"renders/cloud-native-servicemesh-network.mp4",
|
||||
]);
|
||||
expect(result.artifacts.map((entry) => entry.contentType).sort()).toEqual([
|
||||
"image/png",
|
||||
"text/markdown",
|
||||
"text/markdown",
|
||||
"video/mp4",
|
||||
]);
|
||||
expect(
|
||||
await fs.readFile(
|
||||
expect(result.artifacts).toEqual([]);
|
||||
await expect(
|
||||
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.stat(
|
||||
path.join(
|
||||
root,
|
||||
"tasks",
|
||||
@ -450,26 +442,7 @@ describe("exportXWorkmateArtifacts", () => {
|
||||
"renders",
|
||||
"cloud-native-servicemesh-network.mp4",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("mp4");
|
||||
expect(
|
||||
await fs.readFile(
|
||||
path.join(
|
||||
root,
|
||||
"tasks",
|
||||
"draft_1779524982823421-3",
|
||||
"turn-1779685283403237342",
|
||||
"assets",
|
||||
"images",
|
||||
"security-identity-evolution",
|
||||
"001-local-permission.png",
|
||||
),
|
||||
"utf8",
|
||||
),
|
||||
).toBe("png");
|
||||
await expect(
|
||||
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "scratch.txt")),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.stat(path.join(root, "tasks", "draft_1779524982823421-3", "turn-1779685283403237342", "renders", "other.mp4")),
|
||||
|
||||
@ -21,42 +21,6 @@ const SKIPPED_DIRS = new Set([
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
const THREAD_DELIVERY_FILE_NAMES = new Set([
|
||||
"DELIVERY.md",
|
||||
"delivery.md",
|
||||
"ffprobe.json",
|
||||
"video.config.json",
|
||||
"index.html",
|
||||
]);
|
||||
|
||||
const THREAD_DELIVERY_DIRS = new Set([
|
||||
"assets",
|
||||
"renders",
|
||||
"render",
|
||||
"exports",
|
||||
"output",
|
||||
"outputs",
|
||||
]);
|
||||
|
||||
const THREAD_DELIVERY_EXTENSIONS = new Set([
|
||||
".mp4",
|
||||
".mov",
|
||||
".webm",
|
||||
".pdf",
|
||||
".docx",
|
||||
".pptx",
|
||||
".xlsx",
|
||||
".md",
|
||||
".html",
|
||||
".json",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".svg",
|
||||
]);
|
||||
|
||||
export type XWorkmateArtifact = {
|
||||
relativePath: string;
|
||||
label: string;
|
||||
@ -218,18 +182,7 @@ export async function exportXWorkmateArtifacts(input: ExportInput): Promise<XWor
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const threadDeliveryCandidates =
|
||||
sinceUnixMs > 0 && scopedCandidates.length === 0 && adoptedCandidates.length === 0
|
||||
? await adoptThreadWorkspaceDeliveryCandidatesIntoScope({
|
||||
workspaceRoot,
|
||||
scopeRoot,
|
||||
artifactScope,
|
||||
sessionKey,
|
||||
existingRelativePaths: new Set(scopedCandidates.map((candidate) => candidate.relativePath)),
|
||||
warnings,
|
||||
})
|
||||
: [];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates, ...threadDeliveryCandidates];
|
||||
const candidates = [...scopedCandidates, ...adoptedCandidates];
|
||||
if (!scopePrepared && candidates.length === 0) {
|
||||
warnings.push("artifact scope is not prepared for this task run");
|
||||
}
|
||||
@ -497,167 +450,6 @@ async function adoptWorkspaceRootCandidatesIntoScope(input: {
|
||||
return adopted;
|
||||
}
|
||||
|
||||
async function adoptThreadWorkspaceDeliveryCandidatesIntoScope(input: {
|
||||
workspaceRoot: string;
|
||||
scopeRoot: string;
|
||||
artifactScope: string;
|
||||
sessionKey: string;
|
||||
existingRelativePaths: Set<string>;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const threadRoots = await resolveCurrentThreadWorkspaceRoots(input.workspaceRoot, input.sessionKey);
|
||||
const adopted: Candidate[] = [];
|
||||
for (const threadRoot of threadRoots) {
|
||||
const candidates = await collectThreadDeliveryCandidates({
|
||||
scanRoot: threadRoot,
|
||||
relativeRoot: threadRoot,
|
||||
warnings: input.warnings,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (input.existingRelativePaths.has(candidate.relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(input.scopeRoot, candidate.relativePath.split("/").join(path.sep));
|
||||
if (!isWithinRoot(input.scopeRoot, targetPath)) {
|
||||
input.warnings.push(`skipped path outside task scope ${candidate.relativePath}`);
|
||||
continue;
|
||||
}
|
||||
if (await fileExists(targetPath)) {
|
||||
input.existingRelativePaths.add(candidate.relativePath);
|
||||
continue;
|
||||
}
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.copyFile(candidate.absolutePath, targetPath);
|
||||
const stat = await fs.stat(targetPath);
|
||||
const realPath = await fs.realpath(targetPath);
|
||||
adopted.push({
|
||||
absolutePath: realPath,
|
||||
relativePath: candidate.relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: candidate.mtimeMs,
|
||||
artifactScope: input.artifactScope,
|
||||
scopeKind: "task",
|
||||
});
|
||||
input.existingRelativePaths.add(candidate.relativePath);
|
||||
}
|
||||
}
|
||||
return adopted;
|
||||
}
|
||||
|
||||
async function resolveCurrentThreadWorkspaceRoots(workspaceRoot: string, sessionKey: string): Promise<string[]> {
|
||||
const roots = new Set<string>();
|
||||
const realWorkspaceRoot = await fs.realpath(workspaceRoot);
|
||||
if (path.basename(realWorkspaceRoot) === sessionKey) {
|
||||
roots.add(realWorkspaceRoot);
|
||||
}
|
||||
const ownerRoots = [
|
||||
path.join(realWorkspaceRoot, "owners", "local", "user"),
|
||||
path.join(realWorkspaceRoot, "owners", "remote", "user"),
|
||||
];
|
||||
for (const ownerRoot of ownerRoots) {
|
||||
if (!(await directoryExists(ownerRoot))) {
|
||||
continue;
|
||||
}
|
||||
let ownerEntries;
|
||||
try {
|
||||
ownerEntries = await fs.readdir(ownerRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const ownerEntry of ownerEntries) {
|
||||
if (!ownerEntry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const candidate = path.join(ownerRoot, ownerEntry.name, "threads", sessionKey);
|
||||
if (!(await directoryExists(candidate))) {
|
||||
continue;
|
||||
}
|
||||
const realCandidate = await fs.realpath(candidate);
|
||||
if (isWithinRoot(realWorkspaceRoot, realCandidate)) {
|
||||
roots.add(realCandidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
async function collectThreadDeliveryCandidates(input: {
|
||||
scanRoot: string;
|
||||
relativeRoot: string;
|
||||
warnings: string[];
|
||||
}): Promise<Candidate[]> {
|
||||
const candidates: Candidate[] = [];
|
||||
await walk(input.scanRoot, []);
|
||||
return candidates;
|
||||
|
||||
async function walk(currentDir: string, segments: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
input.warnings.push(`cannot read ${safeDisplayPath(input.relativeRoot, currentDir)}: ${String(error)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "." || entry.name === "..") {
|
||||
continue;
|
||||
}
|
||||
const absolutePath = path.join(currentDir, entry.name);
|
||||
const relativeEntryPath = [...segments, entry.name].join("/");
|
||||
if (entry.isSymbolicLink()) {
|
||||
const isDeliveryPath =
|
||||
isThreadDeliveryFile(relativeEntryPath) || THREAD_DELIVERY_DIRS.has(segments[0] ?? entry.name);
|
||||
if (isDeliveryPath) {
|
||||
input.warnings.push(`skipped symlink ${safeDisplayPath(input.relativeRoot, absolutePath)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
if (segments.length === 0 && !THREAD_DELIVERY_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
if (SKIPPED_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(absolutePath, [...segments, entry.name]);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const relativePath = safeRelativePath(input.relativeRoot, absolutePath);
|
||||
if (!relativePath || !isThreadDeliveryFile(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const realPath = await fs.realpath(absolutePath);
|
||||
if (!isWithinRoot(input.relativeRoot, realPath)) {
|
||||
input.warnings.push(`skipped path outside workspace ${entry.name}`);
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
absolutePath: realPath,
|
||||
relativePath,
|
||||
sizeBytes: stat.size,
|
||||
mtimeMs: Math.max(stat.mtimeMs, stat.ctimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isThreadDeliveryFile(relativePath: string): boolean {
|
||||
const parts = relativePath.split("/");
|
||||
const fileName = parts[parts.length - 1] ?? "";
|
||||
if (parts.length === 1) {
|
||||
return THREAD_DELIVERY_FILE_NAMES.has(fileName);
|
||||
}
|
||||
if (!THREAD_DELIVERY_DIRS.has(parts[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
return THREAD_DELIVERY_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
||||
}
|
||||
|
||||
async function collectCandidates(input: {
|
||||
scanRoot: string;
|
||||
relativeRoot: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user