Skip to content
81 changes: 81 additions & 0 deletions apps/api/src/db/migrations/0058_skills.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
model TEXT,
permission_mode TEXT,
system_prompt_append TEXT,
max_turns INTEGER,
timeout_minutes INTEGER,
vm_size_override TEXT,
provider TEXT,
vm_location TEXT,
workspace_profile TEXT,
devcontainer_config_name TEXT,
task_mode TEXT DEFAULT 'task',
resource_requirements_json TEXT,
default_profile_id TEXT REFERENCES agent_profiles(id) ON DELETE SET NULL,
is_builtin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_project_name
ON skills(project_id, name)
WHERE project_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_global_name
ON skills(user_id, name)
WHERE project_id IS NULL;

CREATE INDEX IF NOT EXISTS idx_skills_project_id
ON skills(project_id);

CREATE INDEX IF NOT EXISTS idx_skills_user_id
ON skills(user_id);

CREATE INDEX IF NOT EXISTS idx_skills_default_profile_id
ON skills(default_profile_id);

CREATE TABLE IF NOT EXISTS skill_runtime_env_vars (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
env_key TEXT NOT NULL,
stored_value TEXT NOT NULL,
value_iv TEXT,
is_secret INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_runtime_env_skill_key
ON skill_runtime_env_vars(skill_id, env_key);

CREATE INDEX IF NOT EXISTS idx_skill_runtime_env_user_skill
ON skill_runtime_env_vars(user_id, skill_id);

CREATE TABLE IF NOT EXISTS skill_runtime_files (
id TEXT PRIMARY KEY,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
stored_content TEXT NOT NULL,
content_iv TEXT,
is_secret INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_skill_runtime_files_skill_path
ON skill_runtime_files(skill_id, file_path);

CREATE INDEX IF NOT EXISTS idx_skill_runtime_files_user_skill
ON skill_runtime_files(user_id, skill_id);

ALTER TABLE tasks ADD COLUMN skill_id TEXT REFERENCES skills(id) ON DELETE SET NULL;
ALTER TABLE tasks ADD COLUMN skill_hint TEXT;
ALTER TABLE triggers ADD COLUMN skill_id TEXT REFERENCES skills(id) ON DELETE SET NULL;
99 changes: 99 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,10 @@ export const tasks = sqliteTable(
executionStep: text('execution_step'),
priority: integer('priority').notNull().default(0),
agentProfileHint: text('agent_profile_hint'),
/** Optional skill selected for repeatable-work configuration. */
skillId: text('skill_id'),
/** Original skill hint/id requested by the caller. */
skillHint: text('skill_hint'),
startedAt: text('started_at'),
completedAt: text('completed_at'),
errorMessage: text('error_message'),
Expand Down Expand Up @@ -861,6 +865,54 @@ export const agentProfiles = sqliteTable(
export type AgentProfileRow = typeof agentProfiles.$inferSelect;
export type NewAgentProfileRow = typeof agentProfiles.$inferInsert;

// =============================================================================
// Skills (per-project repeatable-work definitions)
// =============================================================================
export const skills = sqliteTable(
'skills',
{
id: text('id').primaryKey(),
projectId: text('project_id').references(() => projects.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
agentType: text('agent_type').notNull().default('claude-code'),
model: text('model'),
permissionMode: text('permission_mode'),
systemPromptAppend: text('system_prompt_append'),
maxTurns: integer('max_turns'),
timeoutMinutes: integer('timeout_minutes'),
vmSizeOverride: text('vm_size_override'),
provider: text('provider'),
vmLocation: text('vm_location'),
workspaceProfile: text('workspace_profile'),
devcontainerConfigName: text('devcontainer_config_name'),
taskMode: text('task_mode').default('task'),
resourceRequirementsJson: text('resource_requirements_json'),
defaultProfileId: text('default_profile_id').references(() => agentProfiles.id, {
onDelete: 'set null',
}),
isBuiltin: integer('is_builtin').notNull().default(0),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(datetime('now'))`),
},
(table) => ({
projectNameUnique: uniqueIndex('idx_skills_project_name').on(table.projectId, table.name),
projectIdIdx: index('idx_skills_project_id').on(table.projectId),
userIdIdx: index('idx_skills_user_id').on(table.userId),
defaultProfileIdx: index('idx_skills_default_profile_id').on(table.defaultProfileId),
})
);

export type SkillRow = typeof skills.$inferSelect;
export type NewSkillRow = typeof skills.$inferInsert;

const profileRuntimeBaseColumns = () => ({
id: text('id').primaryKey(),
profileId: text('profile_id')
Expand Down Expand Up @@ -917,6 +969,51 @@ export const profileRuntimeFiles = sqliteTable(
})
);

const skillRuntimeBaseColumns = () => ({
id: text('id').primaryKey(),
skillId: text('skill_id')
.notNull()
.references(() => skills.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
isSecret: integer('is_secret', { mode: 'boolean' }).notNull().default(false),
createdAt: text('created_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

export const skillRuntimeEnvVars = sqliteTable(
'skill_runtime_env_vars',
{
...skillRuntimeBaseColumns(),
envKey: text('env_key').notNull(),
storedValue: text('stored_value').notNull(),
valueIv: text('value_iv'),
},
(table) => ({
skillKeyUnique: uniqueIndex('idx_skill_runtime_env_skill_key').on(table.skillId, table.envKey),
userSkillIdx: index('idx_skill_runtime_env_user_skill').on(table.userId, table.skillId),
})
);

export const skillRuntimeFiles = sqliteTable(
'skill_runtime_files',
{
...skillRuntimeBaseColumns(),
filePath: text('file_path').notNull(),
storedContent: text('stored_content').notNull(),
contentIv: text('content_iv'),
},
(table) => ({
skillPathUnique: uniqueIndex('idx_skill_runtime_files_skill_path').on(table.skillId, table.filePath),
userSkillIdx: index('idx_skill_runtime_files_user_skill').on(table.userId, table.skillId),
})
);

// =============================================================================
// UI Governance
// =============================================================================
Expand Down Expand Up @@ -1280,6 +1377,8 @@ export const triggers = sqliteTable(
agentProfileId: text('agent_profile_id').references(() => agentProfiles.id, {
onDelete: 'set null',
}),
/** Optional skill for triggered tasks. set null on skill delete — trigger continues with profile/defaults. */
skillId: text('skill_id').references(() => skills.id, { onDelete: 'set null' }),
taskMode: text('task_mode').default('task'),
vmSizeOverride: text('vm_size_override'),
maxConcurrent: integer('max_concurrent').notNull().default(1),
Expand Down
27 changes: 17 additions & 10 deletions apps/api/src/durable-objects/sam-session/tools/dispatch-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ import * as schema from '../../../db/schema';
import type { Env } from '../../../env';
import { log } from '../../../lib/logger';
import { ulid } from '../../../lib/ulid';
import { resolveAgentProfile } from '../../../services/agent-profiles';
import { generateBranchName } from '../../../services/branch-name';
import { resolveProjectAgentDefault } from '../../../services/project-agent-defaults';
import * as projectDataService from '../../../services/project-data';
import { parseSkillResourceRequirementsJson, resolveSkillProfile } from '../../../services/skills';
import { startTaskRunnerDO } from '../../../services/task-runner-do';
import { generateTaskTitle, getTaskTitleConfig } from '../../../services/task-title';
import type { AnthropicToolDef, ToolContext } from '../types';
Expand Down Expand Up @@ -95,6 +95,10 @@ export const dispatchTaskDef: AnthropicToolDef = {
type: 'string',
description: 'Agent profile ID or name to use for configuration.',
},
skillId: {
type: 'string',
description: 'Skill ID or name to use as the repeatable-work configuration layer.',
},
missionId: {
type: 'string',
description: 'Mission ID to associate this task with. Use after create_mission.',
Expand All @@ -114,6 +118,7 @@ interface DispatchTaskInput {
branch?: string;
taskMode?: string;
agentProfileId?: string;
skillId?: string;
missionId?: string;
}

Expand Down Expand Up @@ -177,9 +182,10 @@ export async function dispatchTask(
}

// ── Resolve agent profile ────────────────────────────────────────────
const resolvedProfile = input.agentProfileId
? await resolveAgentProfile(db, input.projectId, input.agentProfileId, ctx.userId, env)
const resolvedProfile = input.agentProfileId || input.skillId
? await resolveSkillProfile(db, input.projectId, input.agentProfileId, input.skillId, ctx.userId, env)
: null;
const skillResourceRequirements = parseSkillResourceRequirementsJson(resolvedProfile?.resourceRequirementsJson);

// ── Resolve config (explicit → profile → project default → platform default) ──
const profileProvider =
Expand Down Expand Up @@ -256,9 +262,10 @@ export async function dispatchTask(

// ── Resource Requirements Resolution (Phase 0 — audit-only) ──
const resolvedReservation = resolveResourceReservation(
{}, // MCP dispatch: no task-level resource requirements in Phase 0
{ skill: skillResourceRequirements },
{
taskId,
skillId: resolvedProfile?.skillId ?? undefined,
agentProfileId: resolvedProfile?.profileId ?? undefined,
projectId: input.projectId,
userId: ctx.userId,
Expand All @@ -271,19 +278,19 @@ export async function dispatchTask(
await env.DATABASE.prepare(
`INSERT INTO tasks (id, project_id, user_id, title, description,
status, execution_step, priority, dispatch_depth, output_branch, created_by,
task_mode, agent_profile_hint, mission_id, triggered_by,
requested_vm_size, requested_vm_size_source, resolved_reservation_json,
task_mode, agent_profile_hint, skill_id, skill_hint, mission_id, triggered_by,
requested_vm_size, requested_vm_size_source, resource_requirements_json, resource_requirements_source, resolved_reservation_json,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 'queued', 'node_selection', ?, 0, ?, ?,
?, ?, ?, 'mcp',
?, ?, ?,
?, ?, ?, ?, ?, 'mcp',
?, ?, ?, ?, ?,
?, ?)`,
).bind(
taskId, input.projectId, ctx.userId,
taskTitle, description, priority, branchName,
ctx.userId,
resolvedTaskMode, resolvedProfile?.profileId ?? null, input.missionId?.trim() || null,
resolvedVmSize, vmSizeSource, JSON.stringify(resolvedReservation),
resolvedTaskMode, resolvedProfile?.profileId ?? null, resolvedProfile?.skillId ?? null, input.skillId ?? null, input.missionId?.trim() || null,
resolvedVmSize, vmSizeSource, resolvedProfile?.resourceRequirementsJson ?? null, resolvedReservation.source, JSON.stringify(resolvedReservation),
now, now,
).run();

Expand Down
Loading
Loading