add header rescan button to apply officer-items changes without restart
New POST /api/rescan re-runs the boot item setup (ensureItemDirs + ensureToolLoader) and returns live item counts; the header button calls it and invalidates the item query caches so the UI refetches from disk. Also fix ensure-tool-loader to write into OFFICER_ITEMS_DIR/extensions (the runtime read path) instead of the now-unread DATA_PATH/extensions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
|||||||
import type { DockItem } from '../Dock';
|
import type { DockItem } from '../Dock';
|
||||||
import { useIsTouch } from '../useIsTouch';
|
import { useIsTouch } from '../useIsTouch';
|
||||||
import { UserMenu } from './UserMenu';
|
import { UserMenu } from './UserMenu';
|
||||||
|
import { RescanButton } from '../Rescan/RescanButton';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
@@ -52,6 +53,7 @@ export function Header({ dockItems }: HeaderProps) {
|
|||||||
*/}
|
*/}
|
||||||
{/* Bug Report — hidden */}
|
{/* Bug Report — hidden */}
|
||||||
{/* <BugReportButton /> */}
|
{/* <BugReportButton /> */}
|
||||||
|
<RescanButton />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { RotateCw } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||||
|
|
||||||
|
// Item query caches the header refreshes after a rescan (matches the queryKeys used by the
|
||||||
|
// Automation and Capability pages).
|
||||||
|
const ITEM_QUERY_KEYS = ['tasks', 'skills', 'tools', 'processes'];
|
||||||
|
|
||||||
|
export function RescanButton() {
|
||||||
|
const client = useClient();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const rescan = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post<RescanResponse>('/rescan', {});
|
||||||
|
await Promise.all(ITEM_QUERY_KEYS.map((key) => qc.invalidateQueries({ queryKey: [key] })));
|
||||||
|
const c = res.counts ?? {};
|
||||||
|
toast.success(`Rescanned items — ${c.tasks ?? 0} tasks, ${c.tools ?? 0} tools, ${c.skills ?? 0} skills, ${c.processes ?? 0} processes`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to rescan items');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={rescan}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/20 text-duck-dark transition-transform hover:scale-110 disabled:opacity-50"
|
||||||
|
aria-label="Rescan items"
|
||||||
|
title="Rescan officer-items (apply changes without restarting)"
|
||||||
|
>
|
||||||
|
<RotateCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readdirSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createRouter } from '../../create-router';
|
||||||
|
import { ITEM_TYPES, itemsDir, ensureItemDirs, type ItemType } from '../../data-path';
|
||||||
|
import { ensureToolLoader } from '../../ensure-tool-loader';
|
||||||
|
|
||||||
|
// Re-applies the boot-time item setup so changes under OFFICER_ITEMS_DIR take effect without a
|
||||||
|
// server restart. Item lists are already read fresh from disk on every request; this ensures the
|
||||||
|
// type dirs exist and the tool-loader extension is current, and reports the live item counts so the
|
||||||
|
// UI can refetch.
|
||||||
|
|
||||||
|
const CANONICAL: Record<ItemType, string> = {
|
||||||
|
skills: 'SKILL.md',
|
||||||
|
tools: 'TOOL.md',
|
||||||
|
tasks: 'TASK.md',
|
||||||
|
processes: 'PROCESS.md',
|
||||||
|
extensions: 'index.ts',
|
||||||
|
};
|
||||||
|
|
||||||
|
function countItems(type: ItemType): number {
|
||||||
|
const dir = itemsDir(type);
|
||||||
|
if (!existsSync(dir)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory() && existsSync(join(dir, entry.name, CANONICAL[type]))) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rescanRouter = createRouter();
|
||||||
|
|
||||||
|
rescanRouter.post('/', async (ctx) => {
|
||||||
|
ensureItemDirs();
|
||||||
|
ensureToolLoader();
|
||||||
|
|
||||||
|
const counts = Object.fromEntries(ITEM_TYPES.map((type) => [type, countItems(type)])) as Record<ItemType, number>;
|
||||||
|
|
||||||
|
return ctx.json({ ok: true, counts });
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { DATA_PATH } from './data-path';
|
import { itemsDir } from './data-path';
|
||||||
|
|
||||||
const SOURCE_PATH = resolve(import.meta.dir, 'tool-loader-source.ts');
|
const SOURCE_PATH = resolve(import.meta.dir, 'tool-loader-source.ts');
|
||||||
|
|
||||||
export function ensureToolLoader(): void {
|
export function ensureToolLoader(): void {
|
||||||
const targetDir = join(DATA_PATH, 'extensions', 'tool-loader');
|
const targetDir = join(itemsDir('extensions'), 'tool-loader');
|
||||||
const targetFile = join(targetDir, 'index.ts');
|
const targetFile = join(targetDir, 'index.ts');
|
||||||
|
|
||||||
mkdirSync(targetDir, { recursive: true });
|
mkdirSync(targetDir, { recursive: true });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { skillsRouter } from './api/skills/skills';
|
|||||||
import { tasksRouter } from './api/tasks/tasks';
|
import { tasksRouter } from './api/tasks/tasks';
|
||||||
import { toolsRouter } from './api/tools/tools';
|
import { toolsRouter } from './api/tools/tools';
|
||||||
import { processesRouter } from './api/processes/processes';
|
import { processesRouter } from './api/processes/processes';
|
||||||
|
import { rescanRouter } from './api/items/rescan';
|
||||||
import { scrapeRouter } from './api/scrape/scrape';
|
import { scrapeRouter } from './api/scrape/scrape';
|
||||||
import { uploadRouter } from './api/upload/upload';
|
import { uploadRouter } from './api/upload/upload';
|
||||||
import { settingsRouter } from './api/settings/settings';
|
import { settingsRouter } from './api/settings/settings';
|
||||||
@@ -85,6 +86,7 @@ protectedRouter.route('/skills', skillsRouter);
|
|||||||
protectedRouter.route('/tasks', tasksRouter);
|
protectedRouter.route('/tasks', tasksRouter);
|
||||||
protectedRouter.route('/tools', toolsRouter);
|
protectedRouter.route('/tools', toolsRouter);
|
||||||
protectedRouter.route('/processes', processesRouter);
|
protectedRouter.route('/processes', processesRouter);
|
||||||
|
protectedRouter.route('/rescan', rescanRouter);
|
||||||
protectedRouter.route('/scrape', scrapeRouter);
|
protectedRouter.route('/scrape', scrapeRouter);
|
||||||
protectedRouter.route('/upload', uploadRouter);
|
protectedRouter.route('/upload', uploadRouter);
|
||||||
protectedRouter.route('/user', settingsRouter);
|
protectedRouter.route('/user', settingsRouter);
|
||||||
|
|||||||
Reference in New Issue
Block a user