fix: route AgentSkillService to per-tenant schema to match SDK engine

Previously AgentSkillService wrote skills to public.agent_skills (TypeORM
entity with tenantId column filter), while ClaudeAgentSdkEngine read from
it0_t_{tenantId}.skills (per-tenant schema). The two tables were never
connected, so any skill added via the CRUD API was invisible to the agent.

This fix:
- Rewrites AgentSkillService to use DataSource + raw SQL against the
  per-tenant schema it0_t_{tenantId}.skills
- Maps API fields: script→content, enabled→is_active
- Removes AgentSkillRepository and AgentSkill entity from module (no longer needed)
- CRUD API response shape is unchanged (fields mapped back to script/enabled)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-27 21:21:36 -08:00
parent f5d9b1f04f
commit ce4e7840ec
2 changed files with 108 additions and 35 deletions

View File

@ -23,7 +23,6 @@ import { SessionRepository } from './infrastructure/repositories/session.reposit
import { TaskRepository } from './infrastructure/repositories/task.repository';
import { TenantAgentConfigRepository } from './infrastructure/repositories/tenant-agent-config.repository';
import { AgentConfigRepository } from './infrastructure/repositories/agent-config.repository';
import { AgentSkillRepository } from './infrastructure/repositories/agent-skill.repository';
import { HookScriptRepository } from './infrastructure/repositories/hook-script.repository';
import { TenantAgentConfigService } from './infrastructure/services/tenant-agent-config.service';
import { AgentConfigService } from './infrastructure/services/agent-config.service';
@ -35,7 +34,6 @@ import { CommandRecord } from './domain/entities/command-record.entity';
import { StandingOrderRef } from './domain/entities/standing-order.entity';
import { TenantAgentConfig } from './domain/entities/tenant-agent-config.entity';
import { AgentConfig } from './domain/entities/agent-config.entity';
import { AgentSkill } from './domain/entities/agent-skill.entity';
import { HookScript } from './domain/entities/hook-script.entity';
import { ConversationMessage } from './domain/entities/conversation-message.entity';
import { MessageRepository } from './infrastructure/repositories/message.repository';
@ -47,7 +45,7 @@ import { ConversationContextService } from './domain/services/conversation-conte
DatabaseModule.forRoot(),
TypeOrmModule.forFeature([
AgentSession, AgentTask, CommandRecord, StandingOrderRef,
TenantAgentConfig, AgentConfig, AgentSkill, HookScript,
TenantAgentConfig, AgentConfig, HookScript,
ConversationMessage,
]),
],
@ -72,7 +70,6 @@ import { ConversationContextService } from './domain/services/conversation-conte
MessageRepository,
TenantAgentConfigRepository,
AgentConfigRepository,
AgentSkillRepository,
HookScriptRepository,
TenantAgentConfigService,
AgentConfigService,

View File

@ -1,9 +1,16 @@
/**
* Service for managing per-tenant agent skills (CRUD).
*
* Skills are stored in the per-tenant schema table `it0_t_{tenantId}.skills`
* so the Claude Agent SDK engine can read them directly during task execution.
*
* Field mapping between API and DB:
* API `script` DB `content`
* API `enabled` DB `is_active`
*/
import { Injectable } from '@nestjs/common';
import { AgentSkillRepository } from '../repositories/agent-skill.repository';
import { AgentSkill } from '../../domain/entities/agent-skill.entity';
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
export interface CreateSkillDto {
name: string;
@ -23,43 +30,112 @@ export interface UpdateSkillDto {
enabled?: boolean;
}
export interface SkillRecord {
id: string;
tenant_id: string;
name: string;
description: string;
category: string;
script: string; // mapped from DB `content`
tags: string[];
enabled: boolean; // mapped from DB `is_active`
createdAt: Date;
updatedAt: Date;
}
@Injectable()
export class AgentSkillService {
constructor(private readonly repo: AgentSkillRepository) {}
private readonly logger = new Logger(AgentSkillService.name);
async findAllByTenant(tenantId: string): Promise<{ data: AgentSkill[]; total: number }> {
const skills = await this.repo.findByTenantId(tenantId);
return { data: skills, total: skills.length };
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
private schema(tenantId: string): string {
return `it0_t_${tenantId.replace(/[^a-zA-Z0-9_]/g, '')}`;
}
async create(tenantId: string, dto: CreateSkillDto): Promise<AgentSkill> {
const skill = new AgentSkill();
skill.tenantId = tenantId;
skill.name = dto.name;
skill.description = dto.description ?? '';
skill.category = dto.category ?? 'custom';
skill.script = dto.script ?? '';
skill.tags = dto.tags ?? [];
skill.enabled = dto.enabled ?? true;
return this.repo.save(skill);
private toRecord(row: any): SkillRecord {
return {
id: row.id,
tenant_id: row.tenant_id,
name: row.name,
description: row.description ?? '',
category: row.category ?? 'custom',
script: row.content ?? '',
tags: row.tags ?? [],
enabled: row.is_active ?? true,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async update(id: string, tenantId: string, dto: UpdateSkillDto): Promise<AgentSkill | null> {
const skill = await this.repo.findOneByIdAndTenant(id, tenantId);
if (!skill) return null;
if (dto.name !== undefined) skill.name = dto.name;
if (dto.description !== undefined) skill.description = dto.description;
if (dto.category !== undefined) skill.category = dto.category;
if (dto.script !== undefined) skill.script = dto.script;
if (dto.tags !== undefined) skill.tags = dto.tags;
if (dto.enabled !== undefined) skill.enabled = dto.enabled;
return this.repo.save(skill);
async findAllByTenant(tenantId: string): Promise<{ data: SkillRecord[]; total: number }> {
const schema = this.schema(tenantId);
const rows = await this.dataSource.query(
`SELECT * FROM "${schema}".skills ORDER BY created_at DESC`,
);
const data = rows.map((r: any) => this.toRecord(r));
return { data, total: data.length };
}
async create(tenantId: string, dto: CreateSkillDto): Promise<SkillRecord> {
const schema = this.schema(tenantId);
const rows = await this.dataSource.query(
`INSERT INTO "${schema}".skills
(tenant_id, name, description, category, content, tags, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
tenantId,
dto.name,
dto.description ?? '',
dto.category ?? 'custom',
dto.script ?? '',
dto.tags ?? [],
dto.enabled ?? true,
],
);
return this.toRecord(rows[0]);
}
async update(id: string, tenantId: string, dto: UpdateSkillDto): Promise<SkillRecord | null> {
const schema = this.schema(tenantId);
const existing = await this.dataSource.query(
`SELECT * FROM "${schema}".skills WHERE id = $1`,
[id],
);
if (!existing.length) return null;
const row = existing[0];
const rows = await this.dataSource.query(
`UPDATE "${schema}".skills SET
name = $1,
description = $2,
category = $3,
content = $4,
tags = $5,
is_active = $6,
updated_at = now()
WHERE id = $7
RETURNING *`,
[
dto.name ?? row.name,
dto.description ?? row.description,
dto.category ?? row.category,
dto.script ?? row.content,
dto.tags ?? row.tags,
dto.enabled ?? row.is_active,
id,
],
);
return this.toRecord(rows[0]);
}
async delete(id: string, tenantId: string): Promise<boolean> {
const skill = await this.repo.findOneByIdAndTenant(id, tenantId);
if (!skill) return false;
await this.repo.deleteById(id);
return true;
const schema = this.schema(tenantId);
const result = await this.dataSource.query(
`DELETE FROM "${schema}".skills WHERE id = $1 RETURNING id`,
[id],
);
return result.length > 0;
}
}