script-mode task execution — database-backed tasks with direct script runner

Tasks now live in the database (mode: script or agentic). Script-mode tasks
bypass the agent entirely — the implementation is materialized to a temp file
and executed directly, with stdout/stderr streamed to the UI via WebSocket.

Includes convert-to-mp3 as the first native script task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:07:59 +00:00
co-authored by Claude Opus 4.6
parent 1159978187
commit f32f427972
15 changed files with 3238 additions and 209 deletions
@@ -0,0 +1,4 @@
ALTER TABLE "tasks" ADD COLUMN "mode" text DEFAULT 'agentic' NOT NULL;--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "language" text;--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "implementation" text;--> statement-breakpoint
ALTER TABLE "tasks" ADD COLUMN "args" jsonb;
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1772799835768,
"tag": "0001_freezing_carmella_unuscione",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1772985087180,
"tag": "0002_cute_doorman",
"breakpoints": true
}
]
}
+166
View File
@@ -0,0 +1,166 @@
/**
* 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 (simplified — store as-is for now)
const inputsMatch = yaml.match(/^inputs:\s*\n((?:[ \t]+.+\n?)*)/m);
if (inputsMatch) {
const inputBlock = inputsMatch[1]!;
const inputEntries: Record<string, Record<string, string>> = {};
let currentInput: string | null = null;
for (const line of inputBlock.split('\n')) {
const topMatch = line.match(/^\s{2}(\w[\w_-]*):\s*$/);
if (topMatch) {
currentInput = topMatch[1]!;
inputEntries[currentInput] = {};
continue;
}
const propMatch = line.match(/^\s{4}(\w[\w_-]*):\s*(.+)$/);
if (propMatch && currentInput) {
inputEntries[currentInput]![propMatch[1]!] = propMatch[2]!.trim();
}
}
if (Object.keys(inputEntries).length > 0) {
meta.inputs = inputEntries;
}
}
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,
};
// 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,
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);
});
+9
View File
@@ -72,5 +72,14 @@ export {
deleteSavedSession,
} from './queries/saved-sessions';
export {
getTasksForUser,
getTaskById,
getTaskByDirName,
createTask,
updateTask,
deleteTask,
} from './queries/tasks';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,74 @@
import { eq, or, and, isNull, sql } from 'drizzle-orm';
import { db } from '../db';
import { tasks } from '../schema/agent-items';
export async function getTasksForUser(userId: number) {
return db
.select({
id: tasks.id,
dirName: tasks.dirName,
name: tasks.name,
description: tasks.description,
mode: tasks.mode,
language: tasks.language,
version: tasks.version,
scope: tasks.scope,
trigger: tasks.trigger,
userId: tasks.userId,
})
.from(tasks)
.where(
or(
eq(tasks.scope, 'native'),
eq(tasks.scope, 'global'),
and(eq(tasks.scope, 'user'), eq(tasks.userId, userId)),
),
)
.orderBy(tasks.name);
}
export async function getTaskById(id: number) {
const rows = await db.select().from(tasks).where(eq(tasks.id, id)).limit(1);
return rows[0] ?? null;
}
export async function getTaskByDirName(dirName: string, userId: number) {
// User scope takes priority over global, which takes priority over native
const rows = await db
.select()
.from(tasks)
.where(
and(
eq(tasks.dirName, dirName),
or(
eq(tasks.scope, 'native'),
eq(tasks.scope, 'global'),
and(eq(tasks.scope, 'user'), eq(tasks.userId, userId)),
),
),
)
.orderBy(sql`CASE scope WHEN 'user' THEN 0 WHEN 'global' THEN 1 ELSE 2 END`)
.limit(1);
return rows[0] ?? null;
}
type TaskInsert = typeof tasks.$inferInsert;
export async function createTask(data: TaskInsert) {
const rows = await db.insert(tasks).values(data).returning();
return rows[0]!;
}
export async function updateTask(id: number, data: Partial<TaskInsert>) {
const rows = await db
.update(tasks)
.set({ ...data, updatedAt: new Date() })
.where(eq(tasks.id, id))
.returning();
return rows[0] ?? null;
}
export async function deleteTask(id: number) {
await db.delete(tasks).where(eq(tasks.id, id));
}
@@ -19,6 +19,10 @@ export const tasks = pgTable(
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
mode: text('mode').notNull().default('agentic'),
language: text('language'),
implementation: text('implementation'),
args: jsonb('args').$type<string[]>(),
tags: jsonb('tags').$type<string[]>(),
tools: jsonb('tools').$type<string[]>(),
skills: jsonb('skills').$type<string[]>(),