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
@@ -0,0 +1,6 @@
DROP TABLE "extensions" CASCADE;--> statement-breakpoint
DROP TABLE "item_chats" CASCADE;--> statement-breakpoint
DROP TABLE "processes" CASCADE;--> statement-breakpoint
DROP TABLE "skills" CASCADE;--> statement-breakpoint
DROP TABLE "tasks" CASCADE;--> statement-breakpoint
DROP TABLE "tools" CASCADE;
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,13 @@
"when": 1773040399200,
"tag": "0003_perpetual_james_howlett",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1784593790333,
"tag": "0004_true_annihilus",
"breakpoints": true
}
]
}
-11
View File
@@ -72,17 +72,6 @@ export {
deleteSavedSession,
} from './queries/saved-sessions';
export {
getTasksForUser,
getTaskById,
getTaskByDirName,
createTask,
updateTask,
deleteTask,
upsertNativeTask,
deleteNativeTasksNotIn,
} from './queries/tasks';
export {
createPipelineJob,
getPipelineJob,
@@ -1,100 +0,0 @@
import { eq, or, and, isNull, notInArray, 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));
}
export async function deleteNativeTasksNotIn(dirNames: string[]) {
if (dirNames.length === 0) return [];
const deleted = await db
.delete(tasks)
.where(and(eq(tasks.scope, 'native'), notInArray(tasks.dirName, dirNames)))
.returning({ dirName: tasks.dirName });
return deleted;
}
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 });
}
}
@@ -1,159 +0,0 @@
import { pgTable, serial, text, integer, timestamp, jsonb, index, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
// ── Shared columns pattern ──
// Each table has: id, scope, userId, dirName, name, description, body, version, timestamps
// Type-specific columns are added per table
// ── Tasks ──
// Complex frontmatter: inputs, outputs, dependencies, triggers, config, tags, tools, skills
export const tasks = pgTable(
'tasks',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
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[]>(),
inputs: jsonb('inputs'),
outputs: jsonb('outputs'),
dependencies: jsonb('dependencies'),
config: jsonb('config'),
trigger: jsonb('trigger'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_tasks_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tasks_scope').on(table.scope),
index('idx_tasks_user').on(table.userId),
],
);
// ── Skills ──
// Minimal frontmatter: name, description only. Rich markdown body.
export const skills = pgTable(
'skills',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_skills_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_skills_scope').on(table.scope),
index('idx_skills_user').on(table.userId),
],
);
// ── Processes ──
// Same shape as skills. Represents documented workflows.
export const processes = pgTable(
'processes',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_processes_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_processes_scope').on(table.scope),
index('idx_processes_user').on(table.userId),
],
);
// ── Tools ──
// Has implementation code, language, structured input params, label.
export const tools = pgTable(
'tools',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
label: text('label'),
description: text('description'),
body: text('body'),
version: integer('version').notNull().default(1),
language: text('language'),
inputs: jsonb('inputs'),
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_tools_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_tools_scope').on(table.scope),
index('idx_tools_user').on(table.userId),
],
);
// ── Extensions ──
// Code-only, no markdown, no chat. Just implementation.
export const extensions = pgTable(
'extensions',
{
id: serial('id').primaryKey(),
scope: text('scope').notNull(),
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
dirName: text('dir_name').notNull(),
name: text('name').notNull(),
implementation: text('implementation'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_extensions_scope_user_dir').on(table.scope, table.userId, table.dirName),
index('idx_extensions_scope').on(table.scope),
index('idx_extensions_user').on(table.userId),
],
);
// ── Item Chats ──
// Chat history for tasks, skills, processes, resources, tools.
// Uses polymorphic reference (item_type + item_id) instead of per-table FKs.
export const itemChats = pgTable(
'item_chats',
{
id: text('id').primaryKey(),
itemType: text('item_type').notNull(),
itemId: integer('item_id').notNull(),
messages: jsonb('messages').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
unique('uq_item_chats_type_item').on(table.itemType, table.itemId),
index('idx_item_chats_type_item').on(table.itemType, table.itemId),
],
);
@@ -2,7 +2,6 @@ export * from './auth';
export * from './user-data';
export * from './chat';
export * from './dashboards';
export * from './agent-items';
export * from './operations';
export * from './server';
export * from './email';
-30
View File
@@ -50,36 +50,6 @@ export type ScreenInsert = typeof Schema.screens.$inferInsert;
export type ProjectSelect = typeof Schema.projects.$inferSelect;
export type ProjectInsert = typeof Schema.projects.$inferInsert;
// ── Tasks ──
export type TaskSelect = typeof Schema.tasks.$inferSelect;
export type TaskInsert = typeof Schema.tasks.$inferInsert;
// ── Skills ──
export type SkillSelect = typeof Schema.skills.$inferSelect;
export type SkillInsert = typeof Schema.skills.$inferInsert;
// ── Processes ──
export type ProcessSelect = typeof Schema.processes.$inferSelect;
export type ProcessInsert = typeof Schema.processes.$inferInsert;
// ── Tools ──
export type ToolSelect = typeof Schema.tools.$inferSelect;
export type ToolInsert = typeof Schema.tools.$inferInsert;
// ── Extensions ──
export type ExtensionSelect = typeof Schema.extensions.$inferSelect;
export type ExtensionInsert = typeof Schema.extensions.$inferInsert;
// ── Item Chats ──
export type ItemChatSelect = typeof Schema.itemChats.$inferSelect;
export type ItemChatInsert = typeof Schema.itemChats.$inferInsert;
// ── Operations ──
export type TaskLogSelect = typeof Schema.taskLogs.$inferSelect;