unify agent items into a flat file-based store, drop the marketplace

Replace the marketplace service dependency and the native/global/user
scope tiers with a single external directory ($OFFICER_ITEMS_DIR) holding
skills, tools, tasks, processes and extensions as plain files.

- tasks move from Postgres to TASK.md files (new file-backed task layer);
  task editing now works, which the DB path never supported
- skills/tools/processes collapse into one shared file router (single dir)
- remove the marketplace client (sync-marketplace/sync-version) and the
  boot-time sync; pi-bridge/pi-manager/sandbox point at the flat store
- drop the dead tasks + vestigial skills/tools/processes/extensions +
  item_chats tables (migration 0004)
- one-time migration script exports DB tasks and consolidates disk items

Migration verified: all 6 tasks round-trip through the runtime parser
identically to their DB rows (pipeline steps, triggers, script impls and
agentic bodies all intact).

NOTE: not yet functionally tested end-to-end — every item (each task mode,
tool, skill, extension) still needs to be run/exercised in the app before
this is trusted. To be done manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 00:39:17 +00:00
co-authored by Claude Opus 4.8
parent 5fd3d4faac
commit f3492512ba
29 changed files with 2366 additions and 1357 deletions
@@ -8,25 +8,21 @@ import { Play, Trash2, Terminal, Bot, Workflow } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Card } from '@/components/Card';
import { TaskRunnerModal } from 'officerdev';
import type { TaskSummary } from 'officerdev';
type TaskDetail = {
id: number;
dirName: string;
name: string;
description: string;
scope: string;
mode: string;
language: string | null;
body: string | null;
inputs: Record<string, unknown> | null;
config: { steps?: Array<{ task: string; foreach?: string }> } | null;
version: number | null;
userId: number | null;
};
const modeLabels: Record<string, { label: string; icon: typeof Terminal; color: string }> = {
@@ -38,13 +34,14 @@ const modeLabels: Record<string, { label: string; icon: typeof Terminal; color:
export const AutomationDetail = () => {
const client = useClient();
const qc = useQueryClient();
const { user } = useAuth();
const [selected, setSelected] = usePanelChannel<TaskSummary | null>('automation:selected-task', null);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [runTask, setRunTask] = useState<TaskSummary | null>(null);
// Reset delete confirm when selection changes
useEffect(() => { setDeleteConfirm(false); }, [selected?.dirName]);
useEffect(() => {
setDeleteConfirm(false);
}, [selected?.dirName]);
const { data: detail } = useQuery<TaskDetail>({
queryKey: ['tasks', selected?.dirName],
@@ -73,7 +70,6 @@ export const AutomationDetail = () => {
);
}
const canModify = selected.scope === 'user' || user?.role === 'Super Admin';
const mode = modeLabels[selected.mode] ?? modeLabels.agentic!;
const ModeIcon = mode.icon;
@@ -88,7 +84,9 @@ export const AutomationDetail = () => {
<span className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70 flex-1 truncate">
{detail?.name ?? selected.name}
</span>
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${mode.color}`}>
<span
className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium ${mode.color}`}
>
<ModeIcon className="h-2.5 w-2.5" />
{mode.label}
</span>
@@ -99,15 +97,13 @@ export const AutomationDetail = () => {
>
<Play className="h-3.5 w-3.5 text-duck-teal" />
</button>
{canModify && (
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-500/10 cursor-pointer transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
</button>
)}
<button
onClick={() => setDeleteConfirm(true)}
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-500/10 cursor-pointer transition-colors"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50 hover:text-red-500" />
</button>
</div>
{/* Content */}
@@ -119,11 +115,6 @@ export const AutomationDetail = () => {
{/* Meta badges */}
<div className="flex flex-wrap gap-2 mb-4">
{detail?.scope && detail.scope !== 'user' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
{detail.scope}
</span>
)}
{detail?.language && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-duck-dark/8 dark:bg-foreground/8 text-duck-dark/50 dark:text-foreground/50 font-medium">
{detail.language}
@@ -139,13 +130,24 @@ export const AutomationDetail = () => {
{/* Pipeline steps */}
{hasSteps && (
<div className="mb-4">
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">Pipeline Steps</h3>
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">
Pipeline Steps
</h3>
<div className="flex flex-col gap-1">
{detail!.config!.steps!.map((step, i) => (
<div key={i} className="flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70 px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3">
<span className="w-5 text-center font-mono text-xs text-duck-dark/40 dark:text-foreground/40">{i + 1}</span>
<div
key={i}
className="flex items-center gap-2 text-sm text-duck-dark/70 dark:text-foreground/70 px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3"
>
<span className="w-5 text-center font-mono text-xs text-duck-dark/40 dark:text-foreground/40">
{i + 1}
</span>
<span className="font-medium">{step.task}</span>
{step.foreach && <span className="text-xs text-duck-dark/40 dark:text-foreground/40">(foreach: {step.foreach})</span>}
{step.foreach && (
<span className="text-xs text-duck-dark/40 dark:text-foreground/40">
(foreach: {step.foreach})
</span>
)}
</div>
))}
</div>
@@ -155,15 +157,26 @@ export const AutomationDetail = () => {
{/* Inputs */}
{hasInputs && (
<div className="mb-4">
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">Inputs</h3>
<h3 className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 uppercase tracking-wider mb-2">
Inputs
</h3>
<div className="flex flex-col gap-1">
{Object.entries(detail!.inputs!).map(([key, def]) => {
const d = def as { type?: string; description?: string; default?: string };
return (
<div key={key} className="flex items-baseline gap-2 text-sm px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3">
<div
key={key}
className="flex items-baseline gap-2 text-sm px-2 py-1 rounded bg-duck-dark/3 dark:bg-foreground/3"
>
<span className="font-mono text-xs text-duck-teal">{key}</span>
{d.type && <span className="text-[10px] text-duck-dark/40 dark:text-foreground/40">{d.type}</span>}
{d.description && <span className="text-xs text-duck-dark/50 dark:text-foreground/50 flex-1">{d.description}</span>}
{d.type && (
<span className="text-[10px] text-duck-dark/40 dark:text-foreground/40">{d.type}</span>
)}
{d.description && (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 flex-1">
{d.description}
</span>
)}
</div>
);
})}
@@ -208,7 +221,9 @@ export const AutomationDetail = () => {
{runTask && (
<TaskRunnerModal
open
onOpenChange={(open) => { if (!open) setRunTask(null); }}
onOpenChange={(open) => {
if (!open) setRunTask(null);
}}
task={runTask}
/>
)}