Files
platform/src/servers/api/tasks/task-files.ts
T
pastilhasandClaude Opus 5 6d673a1321 plugins: a plugin's tasks are read where they live
the last piece of docs/plugin-capabilities.md. skills, affordances and
notification catalogues already loaded from each installed and enabled
plugin's own directory; tasks did not, so the transcribe plugin shipped a
task nothing could see — right-clicking an .m4a offered no Run Task at all.

it turned out to be one function rather than several. every path that runs
a task resolves it through getTaskByDirName and then reads implementation
off the record, so the sources go in there and in listTasks and a plugin's
task is runnable everywhere at once, identity unchanged: still a directory
name, still what pipeline_jobs stores.

the record carries `plugin` and that one field does the rest. updateTask
and deleteTask throw on it, DELETE /api/tasks/:name answers 409 naming the
plugin, and the capability screen shows whose it is instead of an edit and
a delete that cannot work. without the delete guard it was quietly a no-op:
rm -rf on a path in the owner's store that does not exist, then ok: true,
and the task still in the menu.

owner's store first, so a plugin cannot take over a name already in use.

getTaskByDirName now refuses a name containing a separator. it always
joined a caller-supplied string onto a path — from a websocket message and
a job row — and searching several roots is the moment that stops being one
directory nobody could escape.

capabilityRootsFrom is gone; it was dead. capabilitySources replaces it and
carries the provenance, with capabilityRoots the thin map over it that the
loaders that only want paths still use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-24 04:20:18 +00:00

332 lines
12 KiB
TypeScript

import { readdir, mkdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { itemsDir, OFFICER_ITEMS_DIR } from '../../data-path';
import { capabilitySources, type CapabilitySource } from '../../capability-sources';
// File-backed task store. Every task is a directory under $OFFICER_ITEMS_DIR/tasks/<dirName>/ with a
// TASK.md (metadata + prose body) and, for script-mode tasks, a sibling implementation file. There are
// no scope tiers and no database — the directory name is the task's identity.
//
// ── Plus every enabled plugin's own tasks/ directory ──
//
// Read where they live, never copied in — the same rule as skills and affordances, and the same reasons
// (`capability-sources.ts`, `docs/plugin-capabilities.md`). It lands here rather than in each caller
// because EVERY execution path goes through `getTaskByDirName`: the context menu, the runner sockets,
// the job queue and the pipeline executor all resolve a task by its directory name and then read
// `implementation` off the record. Teaching this one function about plugin sources makes a plugin's task
// runnable everywhere at once, and leaves the identity — the directory name — exactly as it was.
//
// The owner's own store is searched FIRST, so a name the owner is already using cannot be taken over by
// a plugin that happened to pick the same directory name.
//
// Writing is a different matter: create, update and delete only ever touch the owner's store, and refuse
// a task that belongs to a plugin. A plugin's task lives in a git checkout that `plugins:update` pulls —
// editing it in place would be overwritten by the next update, and deleting it would make the plugin's
// own files disappear from a screen that says nothing about plugins.
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
export type TaskFrontmatter = {
name: string;
description: string | null;
category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other'
version: number;
mode: string;
// How the task runs: false = background job, true = ephemerally in the modal, 'ask' = the modal
// offers the choice per run.
inline: boolean | 'ask';
language: string | null;
args: string[] | null;
tags: string[] | null;
tools: string[] | null;
skills: string[] | null;
inputs: unknown;
outputs: unknown;
dependencies: unknown;
config: unknown;
trigger: unknown;
};
export type TaskRecord = TaskFrontmatter & {
dirName: string;
body: string;
implementation: string | null;
filePath: string;
/** The plugin that ships it, or null when it is the owner's own. Read-only when set. */
plugin: string | null;
};
export type TaskSummary = {
dirName: string;
name: string;
description: string | null;
category: string | null;
mode: string;
inline: boolean | 'ask';
version: number;
trigger: unknown;
plugin: string | null;
};
// Frontmatter keys, in write order. These mirror the old `tasks` table columns 1:1.
const FM_KEYS = [
'name',
'description',
'category',
'version',
'mode',
'inline',
'language',
'args',
'tags',
'tools',
'skills',
'inputs',
'outputs',
'dependencies',
'config',
'trigger',
] as const;
// Script implementation lives in a sibling file named by language, matching the marketplace registry.
const IMPL_FILE: Record<string, string> = {
bash: 'run.sh',
python: 'run.py',
typescript: 'index.ts',
javascript: 'index.js',
};
const implFileName = (language: string | null) => IMPL_FILE[language ?? 'bash'] ?? 'run.sh';
const tasksRoot = () => itemsDir('tasks');
const taskDir = (dirName: string) => join(tasksRoot(), dirName);
const taskFile = (dirName: string) => join(taskDir(dirName), 'TASK.md');
const asArray = (v: unknown): string[] | null => (Array.isArray(v) ? v.map(String) : null);
export const slugify = (name: string) =>
name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
const body = match ? match[2]! : raw;
const yaml = match ? match[1]! : '';
let parsed: Record<string, unknown> = {};
if (yaml.trim()) {
try {
parsed = (YAML.parse(yaml) as Record<string, unknown>) ?? {};
} catch {
parsed = {};
}
}
const versionRaw = parsed.version;
const version = typeof versionRaw === 'number' ? versionRaw : Number(versionRaw) || 1;
return {
fm: {
name: parsed.name == null ? '' : String(parsed.name),
description: parsed.description == null ? null : String(parsed.description),
category: parsed.category == null ? null : String(parsed.category),
version,
mode: parsed.mode == null ? 'agentic' : String(parsed.mode),
inline: parsed.inline === 'ask' ? 'ask' : parsed.inline === true,
language: parsed.language == null ? null : String(parsed.language),
args: asArray(parsed.args),
tags: asArray(parsed.tags),
tools: asArray(parsed.tools),
skills: asArray(parsed.skills),
inputs: parsed.inputs ?? null,
outputs: parsed.outputs ?? null,
dependencies: parsed.dependencies ?? null,
config: parsed.config ?? null,
trigger: parsed.trigger ?? null,
},
body,
};
}
// Serialize as JSON-flow YAML: every value is JSON.stringify'd, which is valid YAML and round-trips
// losslessly. Bun.YAML.parse also accepts hand-written multi-line YAML, so files stay editable.
function buildTaskMd(fm: Partial<TaskFrontmatter>, body: string): string {
const lines: string[] = ['---'];
for (const key of FM_KEYS) {
const val = (fm as Record<string, unknown>)[key];
if (val === undefined || val === null) continue;
if (Array.isArray(val) && val.length === 0) continue;
lines.push(`${key}: ${JSON.stringify(val)}`);
}
lines.push('---', '');
lines.push(body ?? '');
return lines.join('\n');
}
async function readImplementation(dir: string, language: string | null): Promise<string | null> {
const file = Bun.file(join(dir, implFileName(language)));
return (await file.exists()) ? file.text() : null;
}
// categories.yaml at the root of the items store lists the task categories in the order they should
// appear in the file browser's Run Task submenu. It is optional and purely presentational: the
// platform never decides a task's category, only how known ones sort. A missing, empty or malformed
// file leaves everything to alphabetical ordering.
export async function readCategoryOrder(): Promise<string[]> {
const file = Bun.file(join(OFFICER_ITEMS_DIR, 'categories.yaml'));
if (!(await file.exists())) return [];
try {
const parsed = YAML.parse(await file.text());
if (!Array.isArray(parsed)) return [];
return parsed
.filter((entry): entry is string => typeof entry === 'string')
.map((entry) => entry.trim())
.filter(Boolean);
} catch {
return [];
}
}
/**
* Every task on offer, the owner's own first.
*
* `sources` is injectable so this can be tested against temporary directories — resolving them for real
* reads the database and the plugin directory, which would make a test assert whatever this machine
* happens to have installed.
*/
export async function listTasks(sources?: CapabilitySource[]): Promise<TaskSummary[]> {
const roots = sources ?? (await capabilitySources('tasks'));
const byDirName = new Map<string, TaskSummary>();
for (const source of roots) {
let entries;
try {
entries = await readdir(source.dir, { withFileTypes: true });
} catch {
continue; // most plugins ship no tasks at all — the ordinary case, not a fault
}
for (const entry of entries) {
if (!entry.isDirectory() || byDirName.has(entry.name)) continue;
const file = Bun.file(join(source.dir, entry.name, 'TASK.md'));
if (!(await file.exists())) continue;
const { fm } = parseTaskMd(await file.text());
byDirName.set(entry.name, {
dirName: entry.name,
name: fm.name || entry.name,
description: fm.description,
category: fm.category,
mode: fm.mode,
inline: fm.inline,
version: fm.version,
trigger: fm.trigger ?? [],
plugin: source.plugin,
});
}
}
return [...byDirName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export async function getTaskByDirName(dirName: string, sources?: CapabilitySource[]): Promise<TaskRecord | null> {
// A directory name reaches this from a websocket message and a job row, so it is caller-supplied and
// gets joined onto a path. Traversal would otherwise read any TASK.md on the disk.
if (!dirName || dirName.includes('/') || dirName.includes('\\') || dirName === '.' || dirName === '..') return null;
const roots = sources ?? (await capabilitySources('tasks'));
for (const source of roots) {
const dir = join(source.dir, dirName);
const filePath = join(dir, 'TASK.md');
const file = Bun.file(filePath);
if (!(await file.exists())) continue;
const { fm, body } = parseTaskMd(await file.text());
const implementation = await readImplementation(dir, fm.language);
return { ...fm, dirName, body, implementation, filePath, plugin: source.plugin };
}
return null;
}
type CreateTaskInput = {
name: string;
description?: string | null;
mode?: string;
language?: string | null;
};
export async function createTask(input: CreateTaskInput): Promise<TaskSummary & { filePath: string }> {
const name = input.name.trim();
const dirName = slugify(name);
if (!dirName) throw new Error('Invalid name');
if (await Bun.file(taskFile(dirName)).exists()) throw new Error('Task already exists');
const mode = input.mode ?? 'agentic';
const language = input.language ?? null;
await mkdir(taskDir(dirName), { recursive: true });
await Bun.write(
taskFile(dirName),
buildTaskMd({ name, description: input.description ?? null, version: 1, mode, language }, ''),
);
if (mode === 'script') await Bun.write(join(taskDir(dirName), implFileName(language)), '');
return {
dirName,
name,
description: input.description ?? null,
category: null,
mode,
inline: false,
version: 1,
trigger: [],
plugin: null,
filePath: taskFile(dirName),
};
}
export async function updateTask(
dirName: string,
patch: Partial<TaskFrontmatter> & { body?: string; implementation?: string | null },
): Promise<TaskRecord | null> {
const existing = await getTaskByDirName(dirName);
if (!existing) return null;
if (existing.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and is read-only`);
const merged: TaskFrontmatter = { ...existing, ...patch };
const body = patch.body ?? existing.body;
await Bun.write(taskFile(dirName), buildTaskMd(merged, body));
if (patch.implementation !== undefined && patch.implementation !== null) {
await Bun.write(join(taskDir(dirName), implFileName(merged.language)), patch.implementation);
}
return getTaskByDirName(dirName);
}
/**
* Only ever the owner's own copy.
*
* Without the guard this was quietly a no-op on a plugin's task — `rm -rf` on a path in the owner's store
* that does not exist, followed by a cheerful `{ ok: true }` and a task still in the menu.
*/
export async function deleteTask(dirName: string): Promise<void> {
const existing = await getTaskByDirName(dirName);
if (existing?.plugin) throw new Error(`${dirName} belongs to the ${existing.plugin} plugin and cannot be deleted`);
await rm(taskDir(dirName), { recursive: true, force: true });
}
// Write a full task record to disk (used by the one-time DB→files migration). Overwrites any
// existing task with the same dirName so callers can apply their own precedence via write order.
export async function importTask(
dirName: string,
record: Partial<TaskFrontmatter> & { body?: string; implementation?: string | null },
): Promise<void> {
await mkdir(taskDir(dirName), { recursive: true });
await Bun.write(taskFile(dirName), buildTaskMd(record, record.body ?? ''));
if (record.implementation != null && record.implementation !== '') {
await Bun.write(join(taskDir(dirName), implFileName(record.language ?? null)), record.implementation);
}
}