move tasks to marketplace, sync native tasks on bootstrap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* Seed native tasks into the database.
|
||||
* Run: bun src/databases/officer_db/seed-tasks.ts
|
||||
*/
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from './src/db';
|
||||
import { tasks } from './src/schema/agent-items';
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
const SEED_TASKS_DIR = resolve(import.meta.dir, '../../../seed/tasks');
|
||||
|
||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
function parseFrontmatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { meta: {} as Record<string, unknown>, body: content };
|
||||
|
||||
const yaml = match[1]!;
|
||||
const body = match[2]!;
|
||||
const meta: Record<string, unknown> = {};
|
||||
|
||||
meta.name = yaml.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
meta.description = yaml.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? '';
|
||||
meta.mode = yaml.match(/^mode:\s*(.+)$/m)?.[1]?.trim() ?? 'agentic';
|
||||
meta.language = yaml.match(/^language:\s*(.+)$/m)?.[1]?.trim() ?? undefined;
|
||||
meta.version = parseInt(yaml.match(/^version:\s*(.+)$/m)?.[1]?.trim() ?? '1', 10);
|
||||
|
||||
// Parse args
|
||||
const argsMatch = yaml.match(/^args:\s*\[([^\]]*)\]/m);
|
||||
meta.args = argsMatch ? argsMatch[1]!.split(',').map((a) => a.trim()).filter(Boolean) : undefined;
|
||||
|
||||
// Parse triggers
|
||||
const triggers: TriggerConfig[] = [];
|
||||
const triggerMatch = yaml.match(/^triggers?:\s*\n((?:[ \t]+.+\n?)*)/m);
|
||||
if (triggerMatch) {
|
||||
const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m);
|
||||
for (const item of items) {
|
||||
const type = item.match(/type:\s*(.+)/)?.[1]?.trim();
|
||||
if (type === 'directory') {
|
||||
triggers.push({ type: 'directory' });
|
||||
} else if (type === 'file') {
|
||||
const extBlock = item.match(/extensions:\s*\n((?:\s+-\s*.+\n?)*)/);
|
||||
const extensions = extBlock ? [...extBlock[1]!.matchAll(/^\s+-\s*(.+)$/gm)].map((m) => m[1]!.trim()) : [];
|
||||
if (extensions.length > 0) triggers.push({ type: 'file', extensions });
|
||||
}
|
||||
}
|
||||
}
|
||||
meta.trigger = triggers.length > 0 ? triggers : undefined;
|
||||
|
||||
// Parse inputs
|
||||
const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m);
|
||||
if (inputsMatch) {
|
||||
const inputBlock = inputsMatch[1]!;
|
||||
const inputEntries: Record<string, Record<string, string | string[]>> = {};
|
||||
let currentInput: string | null = null;
|
||||
let currentListKey: string | null = null;
|
||||
let currentList: string[] = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (currentInput && currentListKey && currentList.length > 0) {
|
||||
inputEntries[currentInput]![currentListKey] = currentList;
|
||||
}
|
||||
currentListKey = null;
|
||||
currentList = [];
|
||||
};
|
||||
|
||||
for (const line of inputBlock.split('\n')) {
|
||||
const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/);
|
||||
if (topMatch) {
|
||||
flushList();
|
||||
currentInput = topMatch[1]!;
|
||||
inputEntries[currentInput] = {};
|
||||
continue;
|
||||
}
|
||||
// List item (6 spaces + dash)
|
||||
const listItemMatch = line.match(/^\s{6}-\s*(.+)$/);
|
||||
if (listItemMatch && currentInput && currentListKey) {
|
||||
currentList.push(listItemMatch[1]!.trim());
|
||||
continue;
|
||||
}
|
||||
// Property with value or start of list
|
||||
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
|
||||
if (propMatch && currentInput) {
|
||||
flushList();
|
||||
const value = propMatch[2]!.trim();
|
||||
if (value === '') {
|
||||
// Start of a list (e.g. "options:")
|
||||
currentListKey = propMatch[1]!;
|
||||
currentList = [];
|
||||
} else {
|
||||
inputEntries[currentInput]![propMatch[1]!] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
if (Object.keys(inputEntries).length > 0) {
|
||||
meta.inputs = inputEntries;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse pipeline steps
|
||||
const stepsMatch = yaml.match(/^steps:\s*\n((?:[ \t]+.+\n?)*)/m);
|
||||
if (stepsMatch) {
|
||||
const stepsBlock = stepsMatch[1]!;
|
||||
const steps: Record<string, unknown>[] = [];
|
||||
let currentStep: Record<string, unknown> | null = null;
|
||||
let inInputs = false;
|
||||
let stepInputs: Record<string, string> = {};
|
||||
|
||||
for (const line of stepsBlock.split('\n')) {
|
||||
// New step entry (2 spaces + dash)
|
||||
const stepStart = line.match(/^\s{2}-\s+task:\s*(.+)$/);
|
||||
if (stepStart) {
|
||||
if (currentStep) {
|
||||
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
|
||||
steps.push(currentStep);
|
||||
}
|
||||
currentStep = { task: stepStart[1]!.trim() };
|
||||
stepInputs = {};
|
||||
inInputs = false;
|
||||
continue;
|
||||
}
|
||||
if (!currentStep) continue;
|
||||
|
||||
// Step-level properties (4 spaces)
|
||||
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1]!;
|
||||
const value = propMatch[2]!.trim();
|
||||
if (key === 'inputs' && value === '') {
|
||||
inInputs = true;
|
||||
} else {
|
||||
inInputs = false;
|
||||
currentStep[key] = value;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step input entries (6 spaces)
|
||||
const inputMatch = line.match(/^\s{6}(\w[\w_-]*):\s*(.+)$/);
|
||||
if (inputMatch && inInputs) {
|
||||
const raw = inputMatch[2]!.trim();
|
||||
stepInputs[inputMatch[1]!] = raw.replace(/^["'](.*)["']$/, '$1');
|
||||
}
|
||||
}
|
||||
if (currentStep) {
|
||||
if (Object.keys(stepInputs).length > 0) currentStep.inputs = stepInputs;
|
||||
steps.push(currentStep);
|
||||
}
|
||||
if (steps.length > 0) {
|
||||
meta.config = { steps };
|
||||
}
|
||||
}
|
||||
|
||||
return { meta, body };
|
||||
}
|
||||
|
||||
async function seedTasks() {
|
||||
if (!existsSync(SEED_TASKS_DIR)) {
|
||||
console.log('No seed tasks directory found');
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readdirSync(SEED_TASKS_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const taskDir = join(SEED_TASKS_DIR, entry.name);
|
||||
const taskMdPath = join(taskDir, 'TASK.md');
|
||||
if (!existsSync(taskMdPath)) continue;
|
||||
|
||||
const content = readFileSync(taskMdPath, 'utf-8');
|
||||
const { meta, body } = parseFrontmatter(content);
|
||||
|
||||
// Find implementation file
|
||||
let implementation: string | null = null;
|
||||
const lang = meta.language as string | undefined;
|
||||
const implCandidates = [
|
||||
{ file: 'run.sh', lang: 'bash' },
|
||||
{ file: 'index.ts', lang: 'typescript' },
|
||||
{ file: 'run.py', lang: 'python' },
|
||||
{ file: 'index.js', lang: 'javascript' },
|
||||
];
|
||||
for (const c of implCandidates) {
|
||||
const path = join(taskDir, c.file);
|
||||
if (existsSync(path)) {
|
||||
implementation = readFileSync(path, 'utf-8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const values = {
|
||||
scope: 'native' as const,
|
||||
userId: null,
|
||||
dirName: entry.name,
|
||||
name: (meta.name as string) || entry.name,
|
||||
description: (meta.description as string) || null,
|
||||
body: body || null,
|
||||
version: (meta.version as number) || 1,
|
||||
mode: (meta.mode as string) || 'agentic',
|
||||
language: (meta.language as string) || null,
|
||||
implementation,
|
||||
args: (meta.args as string[]) || null,
|
||||
trigger: (meta.trigger as TriggerConfig[]) || null,
|
||||
inputs: (meta.inputs as Record<string, unknown>) || null,
|
||||
config: (meta.config as Record<string, unknown>) || null,
|
||||
};
|
||||
|
||||
// Upsert: check by dirName + scope since unique constraint doesn't work with NULL userId
|
||||
const existing = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.dirName, values.dirName), eq(tasks.scope, 'native')))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(tasks)
|
||||
.set({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
body: values.body,
|
||||
version: values.version,
|
||||
mode: values.mode,
|
||||
language: values.language,
|
||||
implementation: values.implementation,
|
||||
args: values.args,
|
||||
trigger: values.trigger,
|
||||
inputs: values.inputs,
|
||||
config: values.config,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, existing[0]!.id));
|
||||
} else {
|
||||
await db.insert(tasks).values(values);
|
||||
}
|
||||
|
||||
console.log(`Seeded: ${entry.name} (${meta.mode}/${meta.language})`);
|
||||
}
|
||||
|
||||
console.log('Done seeding tasks');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seedTasks().catch((err) => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -79,6 +79,7 @@ export {
|
||||
createTask,
|
||||
updateTask,
|
||||
deleteTask,
|
||||
upsertNativeTask,
|
||||
} from './queries/tasks';
|
||||
|
||||
export {
|
||||
|
||||
@@ -72,3 +72,20 @@ export async function updateTask(id: number, data: Partial<TaskInsert>) {
|
||||
export async function deleteTask(id: number) {
|
||||
await db.delete(tasks).where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
export async function upsertNativeTask(data: Omit<TaskInsert, 'scope' | 'userId'>) {
|
||||
const existing = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.dirName, data.dirName), eq(tasks.scope, 'native')))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(tasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, existing[0]!.id));
|
||||
} else {
|
||||
await db.insert(tasks).values({ ...data, scope: 'native', userId: null });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { DATA_PATH } from './data-path';
|
||||
import { syncMarketplaceTools } from './sync-marketplace';
|
||||
import { syncMarketplaceTools, syncMarketplaceTasks } from './sync-marketplace';
|
||||
import { ensureToolLoader } from './ensure-tool-loader';
|
||||
// Queue is now owned by the sidecar process
|
||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||
@@ -68,6 +68,7 @@ async function installPi(): Promise<boolean> {
|
||||
}
|
||||
|
||||
await syncMarketplaceTools();
|
||||
await syncMarketplaceTasks();
|
||||
ensureToolLoader();
|
||||
// Queue is initialized by the sidecar process
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from './data-path';
|
||||
import { parseSeedVersion } from './sync-version';
|
||||
import { upsertNativeTask } from 'officerdb';
|
||||
|
||||
const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev';
|
||||
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
||||
|
||||
// ── Tool sync types ──
|
||||
|
||||
type ToolInput = {
|
||||
type: string;
|
||||
description: string;
|
||||
@@ -26,11 +29,35 @@ type MarketplaceTool = {
|
||||
implementation: string;
|
||||
};
|
||||
|
||||
type MarketplaceResponse = {
|
||||
type ToolSyncResponse = {
|
||||
categories: Array<{ name: string; tools: MarketplaceTool[] }>;
|
||||
uncategorized: MarketplaceTool[];
|
||||
};
|
||||
|
||||
// ── Task sync types ──
|
||||
|
||||
type MarketplaceTask = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
body: string;
|
||||
version: number;
|
||||
mode: string;
|
||||
language: string | null;
|
||||
implementation: string | null;
|
||||
args: string[] | null;
|
||||
inputs: Record<string, unknown> | null;
|
||||
trigger: unknown[] | null;
|
||||
config: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type TaskSyncResponse = {
|
||||
categories: Array<{ name: string; tasks: MarketplaceTask[] }>;
|
||||
uncategorized: MarketplaceTask[];
|
||||
};
|
||||
|
||||
// ── Tool sync (writes files to disk) ──
|
||||
|
||||
function buildToolMd(tool: MarketplaceTool): string {
|
||||
const lines = ['---'];
|
||||
lines.push(`name: ${tool.name}`);
|
||||
@@ -72,7 +99,7 @@ export async function syncMarketplaceTools(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as MarketplaceResponse;
|
||||
const data = (await res.json()) as ToolSyncResponse;
|
||||
|
||||
mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true });
|
||||
|
||||
@@ -103,3 +130,46 @@ export async function syncMarketplaceTools(): Promise<void> {
|
||||
console.error('[marketplace] Failed to sync tools:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task sync (upserts into officer_db) ──
|
||||
|
||||
export async function syncMarketplaceTasks(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${MARKETPLACE_URL}/api/tasks/native`);
|
||||
if (!res.ok) {
|
||||
console.error(`[marketplace] Failed to fetch tasks: ${res.status} ${res.statusText}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as TaskSyncResponse;
|
||||
|
||||
const allTasks: MarketplaceTask[] = [];
|
||||
for (const category of data.categories) {
|
||||
allTasks.push(...category.tasks);
|
||||
}
|
||||
if (data.uncategorized) {
|
||||
allTasks.push(...data.uncategorized);
|
||||
}
|
||||
|
||||
for (const task of allTasks) {
|
||||
await upsertNativeTask({
|
||||
dirName: task.dirName,
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
body: task.body,
|
||||
version: task.version,
|
||||
mode: task.mode,
|
||||
language: task.language,
|
||||
implementation: task.implementation,
|
||||
args: task.args,
|
||||
inputs: task.inputs,
|
||||
trigger: task.trigger,
|
||||
config: task.config,
|
||||
});
|
||||
|
||||
console.log(`[marketplace] Synced task: ${task.dirName} (v${task.version})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[marketplace] Failed to sync tasks:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user