diff --git a/PACKAGE_METADATA_IMPLEMENTATION.md b/PACKAGE_METADATA_IMPLEMENTATION.md new file mode 100644 index 000000000..88428ebcd --- /dev/null +++ b/PACKAGE_METADATA_IMPLEMENTATION.md @@ -0,0 +1,319 @@ +# Package-Aware Metadata Management Implementation + +## Overview + +This document describes the implementation of package-aware metadata management in ObjectStack, ensuring that: +1. Every metadata item belongs to a package +2. Code-loaded packages are read-only +3. Database packages are mutable + +## Architecture + +### Core Principles + +1. **Package Ownership**: All metadata (objects, views, flows, etc.) must belong to a package +2. **Source-Based Mutability**: + - **Filesystem/Code packages** → Read-only (scope='system', source='filesystem') + - **Database packages** → Mutable (scope='platform'/'user', source='database') +3. **Conversation Context**: AI tools track active package per conversation +4. **Overlay Pattern**: Code metadata can have database overlays for customization + +### Schema Support (Already Exists) + +The `MetadataRecordSchema` in `packages/spec/src/system/metadata-persistence.zod.ts` already includes: + +```typescript +{ + packageId: string | undefined; // Package ownership + managedBy: 'package' | 'platform' | 'user'; // Lifecycle management + scope: 'system' | 'platform' | 'user'; // Mutability scope + source: 'filesystem' | 'database' | 'api' | 'migration'; // Origin +} +``` + +## Implementation Progress + +### Phase 1: Package Management Tools ✅ COMPLETE + +Created 5 new AI tools for package management: + +1. **`list_packages`** (`list-packages.tool.ts`) + - Lists all installed packages + - Supports filtering by status and enabled state + - Returns package metadata (id, name, version, type, status) + +2. **`get_package`** (`get-package.tool.ts`) + - Gets detailed information about a specific package + - Returns full manifest, dependencies, namespaces + +3. **`create_package`** (`create-package.tool.ts`) + - Creates a new package with manifest + - Validates reverse domain notation for package ID + - Auto-derives namespace from package ID + - Automatically sets as active package in conversation + +4. **`get_active_package`** (`get-active-package.tool.ts`) + - Retrieves the currently active package from conversation context + - Returns null if no active package is set + +5. **`set_active_package`** (`set-active-package.tool.ts`) + - Sets the active package for the conversation + - All subsequent metadata operations use this package + +**Handler Implementation**: `package-tools.ts` +- Implements `IPackageRegistry` interface for package CRUD +- Implements `IConversationService` interface for context tracking +- Validates package IDs (reverse domain notation) +- Validates namespaces (snake_case) +- Validates versions (semver) + +### Phase 2: Enhanced Metadata Tools ⏳ IN PROGRESS + +**Completed:** +- Updated `MetadataToolContext` interface to include: + - `conversationService` - for tracking active package + - `conversationId` - current conversation context + - `packageRegistry` - for validating packages and checking read-only status + +- Added `packageId` parameter to `create_object` tool + - Optional parameter + - Falls back to active package from conversation + - Provides clear error message if no package context available + +**Remaining Work:** +- Update `createObjectHandler` to: + - Resolve package ID (explicit > active > error) + - Check if package is read-only + - Attach package metadata to object definition + - Return package info in success response + +- Update other metadata tools (`add_field`, `modify_field`, `delete_field`) + - Add `packageId` parameter where appropriate + - Implement read-only validation + +### Phase 3: Conversation Context Management (TODO) + +**Objectives:** +- Store `activePackageId` in conversation metadata +- Persist across conversation turns +- Clear on conversation end + +**Implementation Plan:** +```typescript +// In conversation service +interface ConversationMetadata { + activePackageId?: string; + lastPackageOperation?: string; + createdAt?: string; +} + +// Store in database table: ai_conversation_metadata +{ + conversation_id: string; + metadata: JSON; // Contains activePackageId + updated_at: timestamp; +} +``` + +### Phase 4: Metadata Service Write Protection (TODO) + +**Objectives:** +- Prevent modification of code-based metadata +- Allow database metadata modifications +- Support customization overlays for code metadata + +**Implementation Plan:** + +1. **Add source tracking to metadata registration:** +```typescript +// In metadata service +async register(type: string, name: string, data: unknown, options?: { + packageId?: string; + scope?: 'system' | 'platform' | 'user'; + source?: 'filesystem' | 'database' | 'api'; +}): Promise +``` + +2. **Implement read-only check:** +```typescript +async register(type: string, name: string, data: unknown, options) { + const existing = await this.get(type, name); + + if (existing) { + const metadata = existing as MetadataRecord; + + // Block if trying to modify code-based metadata + if (metadata.scope === 'system' || metadata.source === 'filesystem') { + throw new Error( + `Cannot modify ${type} "${name}" - it is code-based metadata. ` + + `Use overlay customization instead via saveOverlay().` + ); + } + } + + // Proceed with registration for database metadata + await this.storage.save(type, name, { ...data, ...options }); +} +``` + +3. **Support overlay pattern:** +```typescript +// Allow customization of code metadata via overlays +await metadataService.saveOverlay({ + type: 'object', + name: 'account', + scope: 'platform', // or 'user' + overlay: { + fields: { + custom_field: { type: 'text', label: 'Custom Field' } + } + } +}); + +// Runtime serves merged result: +// base (from code) + platform overlay + user overlay +const effective = await metadataService.getEffective('object', 'account', context); +``` + +### Phase 5: Testing & Documentation (TODO) + +**Unit Tests Needed:** +- Package tool validation (reverse domain, semver, snake_case) +- Package CRUD operations +- Active package resolution logic +- Read-only package detection +- Metadata service write protection + +**Integration Tests Needed:** +- End-to-end package creation workflow +- Metadata creation with package context +- Read-only enforcement for code packages +- Overlay application and merging + +**Documentation Needed:** +- Package-first development workflow guide +- AI agent integration examples +- Package naming conventions +- Customization overlay patterns +- Migration guide for existing metadata + +## Usage Examples + +### Creating a Package and Objects via AI + +```typescript +// User: "Create a new CRM application" +// AI uses: create_package +{ + id: "com.acme.crm", + name: "CRM Application", + version: "1.0.0", + type: "application" +} + +// AI automatically sets as active package +// Now all metadata creation uses this package + +// User: "Create an Account object with name and email fields" +// AI uses: create_object (packageId is implicit from active package) +{ + name: "account", + label: "Account", + fields: [ + { name: "account_name", type: "text", label: "Account Name" }, + { name: "email", type: "text", label: "Email" } + ] +} + +// Object is created with packageId="com.acme.crm" +``` + +### Handling Read-Only Packages + +```typescript +// Code-based package (loaded from filesystem) +// packages/my-plugin/metadata/objects/user.object.ts +export default defineObject({ + name: 'user', + label: 'User', + fields: { ... } +}); + +// At runtime, this is registered with: +// scope='system', source='filesystem', packageId='com.example.myplugin' + +// User tries: "Add a custom_field to the user object" +// AI uses: add_field +{ + objectName: "user", + name: "custom_field", + type: "text" +} + +// Metadata service blocks: +// "Cannot modify object 'user' - it is code-based metadata. +// Use overlay customization instead." + +// AI suggests alternative: +// "I see 'user' is a system object. I can create a customization overlay instead. +// Would you like me to add the field as a platform-level customization?" +``` + +## Best Practices + +1. **Package Naming**: + - Use reverse domain notation: `com.company.product` + - Examples: `com.acme.crm`, `org.nonprofit.fundraising` + +2. **Namespace Derivation**: + - Auto-derived from last part of package ID + - `com.acme.crm` → namespace: `crm` + - Can be explicitly overridden if needed + +3. **Scope Selection**: + - `system`: Platform/framework code (read-only) + - `platform`: Admin-configured (mutable, applies to all users) + - `user`: User-configured (mutable, personal customizations) + +4. **Source Tracking**: + - `filesystem`: Loaded from code files (read-only) + - `database`: Stored in database (mutable) + - `api`: Loaded from external API + - `migration`: Created during migration + +## Next Steps + +1. Complete Phase 2: Finish enhancing all metadata tool handlers +2. Implement Phase 3: Conversation context persistence +3. Implement Phase 4: Metadata service write protection +4. Write comprehensive tests (Phase 5) +5. Update AI agent system prompts with package-first instructions +6. Create user documentation and migration guide + +## Related Files + +### New Files Created +- `packages/services/service-ai/src/tools/list-packages.tool.ts` +- `packages/services/service-ai/src/tools/get-package.tool.ts` +- `packages/services/service-ai/src/tools/create-package.tool.ts` +- `packages/services/service-ai/src/tools/get-active-package.tool.ts` +- `packages/services/service-ai/src/tools/set-active-package.tool.ts` +- `packages/services/service-ai/src/tools/package-tools.ts` + +### Modified Files +- `packages/services/service-ai/src/index.ts` - Added package tool exports +- `packages/services/service-ai/src/tools/create-object.tool.ts` - Added packageId parameter +- `packages/services/service-ai/src/tools/metadata-tools.ts` - Enhanced context interface + +### Existing Schema Files (Used) +- `packages/spec/src/system/metadata-persistence.zod.ts` - MetadataRecordSchema +- `packages/spec/src/kernel/package-registry.zod.ts` - InstalledPackageSchema +- `packages/spec/src/kernel/manifest.zod.ts` - ManifestSchema +- `packages/spec/src/api/package-api.zod.ts` - Package API contracts +- `packages/spec/src/contracts/metadata-service.ts` - IMetadataService interface + +## Conclusion + +The foundation for package-aware metadata management has been established. The package management tools are complete and ready for use. The next phases will complete the integration with metadata tools and enforce read-only protection for code-based packages. + +This implementation aligns with industry best practices from Salesforce, ServiceNow, and other enterprise low-code platforms, ensuring metadata governance, version control compatibility, and safe upgrade paths. diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index d0388ed32..0c3cf3526 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -43,6 +43,19 @@ export { describeObjectTool, } from './tools/metadata-tools.js'; +// Package tools +export { registerPackageTools, PACKAGE_TOOL_DEFINITIONS } from './tools/package-tools.js'; +export type { PackageToolContext, IPackageRegistry, IConversationService } from './tools/package-tools.js'; + +// Individual package tool metadata +export { + listPackagesTool, + getPackageTool, + createPackageTool, + getActivePackageTool, + setActivePackageTool, +} from './tools/package-tools.js'; + // Agent runtime export { AgentRuntime } from './agent-runtime.js'; export type { AgentChatContext } from './agent-runtime.js'; diff --git a/packages/services/service-ai/src/tools/add-field.tool.ts b/packages/services/service-ai/src/tools/add-field.tool.ts index 307a90446..38071d5f1 100644 --- a/packages/services/service-ai/src/tools/add-field.tool.ts +++ b/packages/services/service-ai/src/tools/add-field.tool.ts @@ -20,6 +20,10 @@ export const addFieldTool = defineTool({ parameters: { type: 'object', properties: { + packageId: { + type: 'string', + description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', + }, objectName: { type: 'string', description: 'Target object machine name (snake_case)', diff --git a/packages/services/service-ai/src/tools/create-object.tool.ts b/packages/services/service-ai/src/tools/create-object.tool.ts index 46b4d28b6..00c4e601c 100644 --- a/packages/services/service-ai/src/tools/create-object.tool.ts +++ b/packages/services/service-ai/src/tools/create-object.tool.ts @@ -33,6 +33,10 @@ export const createObjectTool = defineTool({ type: 'string', description: 'Human-readable display name (e.g. Project Task)', }, + packageId: { + type: 'string', + description: 'Package ID that will own this object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', + }, fields: { type: 'array', description: 'Initial fields to create with the object', diff --git a/packages/services/service-ai/src/tools/create-package.tool.ts b/packages/services/service-ai/src/tools/create-package.tool.ts new file mode 100644 index 000000000..3ad30d503 --- /dev/null +++ b/packages/services/service-ai/src/tools/create-package.tool.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * create_package — AI Tool Metadata + * + * Creates a new package for organizing metadata. + * All metadata (objects, views, flows, etc.) should belong to a package. + */ +export const createPackageTool = defineTool({ + name: 'create_package', + label: 'Create Package', + description: + 'Creates a new package (metadata container) with the specified manifest. ' + + 'All metadata in ObjectStack should belong to a package. Use this when starting new development ' + + 'or when the user wants to organize their metadata into a new module.', + category: 'utility', + builtIn: true, + parameters: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Package identifier in reverse domain notation (e.g., com.acme.crm, org.mycompany.sales)', + }, + name: { + type: 'string', + description: 'Human-readable package name (e.g., "CRM Application", "Sales Module")', + }, + version: { + type: 'string', + description: 'Semantic version (e.g., "1.0.0")', + default: '1.0.0', + }, + description: { + type: 'string', + description: 'Brief description of what this package provides', + }, + namespace: { + type: 'string', + description: 'Namespace prefix for metadata (snake_case, e.g., crm, sales). If not provided, derived from package ID.', + }, + type: { + type: 'string', + description: 'Package type', + enum: ['application', 'plugin', 'library', 'template'], + default: 'application', + }, + }, + required: ['id', 'name'], + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/delete-field.tool.ts b/packages/services/service-ai/src/tools/delete-field.tool.ts index 405f1bd21..8ca0f511a 100644 --- a/packages/services/service-ai/src/tools/delete-field.tool.ts +++ b/packages/services/service-ai/src/tools/delete-field.tool.ts @@ -23,6 +23,10 @@ export const deleteFieldTool = defineTool({ parameters: { type: 'object', properties: { + packageId: { + type: 'string', + description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', + }, objectName: { type: 'string', description: 'Target object machine name (snake_case)', diff --git a/packages/services/service-ai/src/tools/get-active-package.tool.ts b/packages/services/service-ai/src/tools/get-active-package.tool.ts new file mode 100644 index 000000000..19a064b15 --- /dev/null +++ b/packages/services/service-ai/src/tools/get-active-package.tool.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * get_active_package — AI Tool Metadata + * + * Gets the currently active package in the conversation context. + */ +export const getActivePackageTool = defineTool({ + name: 'get_active_package', + label: 'Get Active Package', + description: + 'Gets the currently active package in this conversation. The active package determines ' + + 'where new metadata will be created. Returns null if no package is set.', + category: 'utility', + builtIn: true, + parameters: { + type: 'object', + properties: {}, + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/get-package.tool.ts b/packages/services/service-ai/src/tools/get-package.tool.ts new file mode 100644 index 000000000..6766a0e6b --- /dev/null +++ b/packages/services/service-ai/src/tools/get-package.tool.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * get_package — AI Tool Metadata + * + * Gets detailed information about a specific package. + */ +export const getPackageTool = defineTool({ + name: 'get_package', + label: 'Get Package', + description: + 'Gets detailed information about a specific installed package, including its manifest, ' + + 'metadata, and installation status.', + category: 'utility', + builtIn: true, + parameters: { + type: 'object', + properties: { + packageId: { + type: 'string', + description: 'Package identifier (reverse domain notation, e.g., com.acme.crm)', + }, + }, + required: ['packageId'], + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/list-packages.tool.ts b/packages/services/service-ai/src/tools/list-packages.tool.ts new file mode 100644 index 000000000..92517ade7 --- /dev/null +++ b/packages/services/service-ai/src/tools/list-packages.tool.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * list_packages — AI Tool Metadata + * + * Lists all installed packages in the ObjectStack instance. + * Useful for understanding what packages are available before creating metadata. + */ +export const listPackagesTool = defineTool({ + name: 'list_packages', + label: 'List Packages', + description: + 'Lists all installed packages in the system. Use this to see what packages are available ' + + 'before creating or modifying metadata. Packages are the containers that hold metadata.', + category: 'utility', + builtIn: true, + parameters: { + type: 'object', + properties: { + status: { + type: 'string', + description: 'Filter by package status', + enum: ['installed', 'disabled', 'installing', 'upgrading', 'uninstalling', 'error'], + }, + enabled: { + type: 'boolean', + description: 'Filter by enabled state (true = only enabled, false = only disabled)', + }, + }, + additionalProperties: false, + }, +}); diff --git a/packages/services/service-ai/src/tools/metadata-tools.ts b/packages/services/service-ai/src/tools/metadata-tools.ts index e9b5eb18c..9239ef77f 100644 --- a/packages/services/service-ai/src/tools/metadata-tools.ts +++ b/packages/services/service-ai/src/tools/metadata-tools.ts @@ -69,6 +69,77 @@ function isSnakeCase(value: string): boolean { return SNAKE_CASE_RE.test(value); } +// --------------------------------------------------------------------------- +// Package Resolution Helpers +// --------------------------------------------------------------------------- + +/** + * Retrieves the active package ID from the conversation context. + * Returns null if no conversation service is available or no active package is set. + */ +async function getActivePackageId(ctx: MetadataToolContext): Promise { + if (!ctx.conversationService?.getMetadata || !ctx.conversationId) { + return null; + } + + const metadata = await ctx.conversationService.getMetadata(ctx.conversationId); + return (metadata?.activePackageId as string) ?? null; +} + +/** + * Resolves the package ID to use for a metadata operation. + * Priority: explicit packageId > active package from conversation > error + * + * Also validates that the package exists and checks if it's read-only. + * + * @returns Object with packageId or error message + */ +async function resolvePackageId( + ctx: MetadataToolContext, + explicitPackageId?: string, +): Promise<{ packageId: string | null; error?: string; warning?: string }> { + let packageId: string | null = null; + + // 1. Try explicit packageId parameter + if (explicitPackageId) { + packageId = explicitPackageId; + } else { + // 2. Try active package from conversation + packageId = await getActivePackageId(ctx); + } + + // If no package ID could be resolved, return null (backward compatibility) + // This allows metadata to be stored without package association + if (!packageId) { + return { + packageId: null, + warning: 'No package specified. Metadata will be created without package association. Consider using set_active_package or providing packageId parameter.', + }; + } + + // Validate package exists (if registry is available) + if (ctx.packageRegistry) { + const exists = await ctx.packageRegistry.exists(packageId); + if (!exists) { + return { + packageId: null, + error: `Package "${packageId}" not found. Use list_packages to see available packages or create_package to create a new one.`, + }; + } + + // Check if package is read-only (code-based) + const pkg = await ctx.packageRegistry.get(packageId); + if (pkg?.manifest.source === 'filesystem') { + return { + packageId: null, + error: `Package "${packageId}" is read-only (loaded from code). Only database packages can be modified. Use create_package to create a new database package.`, + }; + } + } + + return { packageId }; +} + // --------------------------------------------------------------------------- // Context — injected once at registration time // --------------------------------------------------------------------------- @@ -82,6 +153,20 @@ function isSnakeCase(value: string): boolean { export interface MetadataToolContext { /** Metadata service for schema CRUD operations. */ metadataService: IMetadataService; + + /** Optional: Conversation service for retrieving active package context */ + conversationService?: { + getMetadata?(conversationId: string): Promise | undefined>; + }; + + /** Optional: Current conversation ID (if in a conversation context) */ + conversationId?: string; + + /** Optional: Package registry for validating package existence */ + packageRegistry?: { + exists(packageId: string): Promise; + get(packageId: string): Promise<{ manifest: { scope?: string; source?: string } } | undefined>; + }; } // --------------------------------------------------------------------------- @@ -90,9 +175,10 @@ export interface MetadataToolContext { function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { return async (args) => { - const { name, label, fields, enableFeatures } = args as { + const { name, label, packageId: explicitPackageId, fields, enableFeatures } = args as { name: string; label: string; + packageId?: string; fields?: Array<{ name: string; label?: string; type: string; required?: boolean }>; enableFeatures?: Record; }; @@ -101,6 +187,13 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ error: 'Both "name" and "label" are required' }); } + // Resolve package ID + const resolved = await resolvePackageId(ctx, explicitPackageId); + if (resolved.error) { + return JSON.stringify({ error: resolved.error }); + } + const packageId = resolved.packageId; + // Validate snake_case name if (!isSnakeCase(name)) { return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` }); @@ -138,6 +231,7 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { const objectDef: Record = { name, label, + ...(packageId ? { packageId } : {}), ...(Object.keys(fieldMap).length > 0 ? { fields: fieldMap } : {}), ...(enableFeatures ? { enable: enableFeatures } : {}), }; @@ -147,6 +241,7 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { return JSON.stringify({ name, label, + ...(packageId ? { packageId } : {}), fieldCount: Object.keys(fieldMap).length, }); }; @@ -154,7 +249,7 @@ function createCreateObjectHandler(ctx: MetadataToolContext): ToolHandler { function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { return async (args) => { - const { objectName, name, label, type, required, defaultValue, options, reference } = args as { + const { objectName, name, label, type, required, defaultValue, options, reference, packageId: explicitPackageId } = args as { objectName: string; name: string; label?: string; @@ -163,12 +258,19 @@ function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { defaultValue?: unknown; options?: Array<{ label: string; value: string }>; reference?: string; + packageId?: string; }; if (!objectName || !name || !type) { return JSON.stringify({ error: '"objectName", "name", and "type" are required' }); } + // Resolve package ID (for validation and tracking) + const resolved = await resolvePackageId(ctx, explicitPackageId); + if (resolved.error) { + return JSON.stringify({ error: resolved.error }); + } + // Validate snake_case names if (!isSnakeCase(objectName)) { return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); @@ -224,22 +326,30 @@ function createAddFieldHandler(ctx: MetadataToolContext): ToolHandler { objectName, fieldName: name, fieldType: type, + packageId: resolved.packageId, }); }; } function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { return async (args) => { - const { objectName, fieldName, changes } = args as { + const { objectName, fieldName, changes, packageId: explicitPackageId } = args as { objectName: string; fieldName: string; changes: Record; + packageId?: string; }; if (!objectName || !fieldName || !changes) { return JSON.stringify({ error: '"objectName", "fieldName", and "changes" are required' }); } + // Resolve package ID (for validation and tracking) + const resolved = await resolvePackageId(ctx, explicitPackageId); + if (resolved.error) { + return JSON.stringify({ error: resolved.error }); + } + // Validate snake_case names if (!isSnakeCase(objectName)) { return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); @@ -273,21 +383,29 @@ function createModifyFieldHandler(ctx: MetadataToolContext): ToolHandler { objectName, fieldName, updatedProperties: Object.keys(changes), + packageId: resolved.packageId, }); }; } function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { return async (args) => { - const { objectName, fieldName } = args as { + const { objectName, fieldName, packageId: explicitPackageId } = args as { objectName: string; fieldName: string; + packageId?: string; }; if (!objectName || !fieldName) { return JSON.stringify({ error: '"objectName" and "fieldName" are required' }); } + // Resolve package ID (for validation and tracking) + const resolved = await resolvePackageId(ctx, explicitPackageId); + if (resolved.error) { + return JSON.stringify({ error: resolved.error }); + } + // Validate snake_case names if (!isSnakeCase(objectName)) { return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` }); @@ -318,6 +436,7 @@ function createDeleteFieldHandler(ctx: MetadataToolContext): ToolHandler { objectName, fieldName, success: true, + packageId: resolved.packageId, }); }; } diff --git a/packages/services/service-ai/src/tools/modify-field.tool.ts b/packages/services/service-ai/src/tools/modify-field.tool.ts index e8fed1242..8c2424ab7 100644 --- a/packages/services/service-ai/src/tools/modify-field.tool.ts +++ b/packages/services/service-ai/src/tools/modify-field.tool.ts @@ -19,6 +19,10 @@ export const modifyFieldTool = defineTool({ parameters: { type: 'object', properties: { + packageId: { + type: 'string', + description: 'Package ID that owns the target object (e.g., com.acme.crm). If not provided, uses the active package from conversation context.', + }, objectName: { type: 'string', description: 'Target object machine name (snake_case)', diff --git a/packages/services/service-ai/src/tools/package-tools.ts b/packages/services/service-ai/src/tools/package-tools.ts new file mode 100644 index 000000000..a1a405c9b --- /dev/null +++ b/packages/services/service-ai/src/tools/package-tools.ts @@ -0,0 +1,396 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Tool } from '@objectstack/spec/ai'; +import type { InstalledPackage } from '@objectstack/spec/kernel'; +import type { ToolHandler } from './tool-registry.js'; +import type { ToolRegistry } from './tool-registry.js'; + +// --------------------------------------------------------------------------- +// Tool Metadata — individual .tool.ts files (single source of truth) +// --------------------------------------------------------------------------- + +export { listPackagesTool } from './list-packages.tool.js'; +export { getPackageTool } from './get-package.tool.js'; +export { createPackageTool } from './create-package.tool.js'; +export { getActivePackageTool } from './get-active-package.tool.js'; +export { setActivePackageTool } from './set-active-package.tool.js'; + +import { listPackagesTool } from './list-packages.tool.js'; +import { getPackageTool } from './get-package.tool.js'; +import { createPackageTool } from './create-package.tool.js'; +import { getActivePackageTool } from './get-active-package.tool.js'; +import { setActivePackageTool } from './set-active-package.tool.js'; + +/** All built-in package management tool definitions (Tool metadata). */ +export const PACKAGE_TOOL_DEFINITIONS: Tool[] = [ + listPackagesTool, + getPackageTool, + createPackageTool, + getActivePackageTool, + setActivePackageTool, +]; + +// --------------------------------------------------------------------------- +// Package Registry Interface (minimal contract for package operations) +// --------------------------------------------------------------------------- + +/** + * Minimal package registry interface for tool operations. + * The actual implementation may be a full PackageRegistry service. + */ +export interface IPackageRegistry { + /** List all installed packages */ + list(filter?: { status?: string; enabled?: boolean }): Promise; + + /** Get a specific package by ID */ + get(packageId: string): Promise; + + /** Install a new package */ + install(manifest: Record): Promise; + + /** Check if a package exists */ + exists(packageId: string): Promise; +} + +// --------------------------------------------------------------------------- +// Conversation Service Interface (for tracking active package) +// --------------------------------------------------------------------------- + +/** + * Minimal conversation service interface for context management. + */ +export interface IConversationService { + /** Get conversation metadata */ + getMetadata?(conversationId: string): Promise | undefined>; + + /** Update conversation metadata */ + updateMetadata?(conversationId: string, metadata: Record): Promise; +} + +// --------------------------------------------------------------------------- +// Context — injected once at registration time +// --------------------------------------------------------------------------- + +/** + * Services required by the package management tools. + * + * Provided by the kernel at `ai:ready` time and closed over + * by the handler functions so they stay framework-agnostic. + */ +export interface PackageToolContext { + /** Package registry for package CRUD operations */ + packageRegistry: IPackageRegistry; + + /** Conversation service for tracking active package context (optional) */ + conversationService?: IConversationService; + + /** Current conversation ID (if in a conversation context) */ + conversationId?: string; +} + +// --------------------------------------------------------------------------- +// Shared validation helpers +// --------------------------------------------------------------------------- + +/** Reverse domain notation pattern (e.g. com.acme.crm). */ +const REVERSE_DOMAIN_RE = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/; + +/** snake_case identifier pattern. */ +const SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/; + +/** Semantic version pattern. */ +const SEMVER_RE = /^\d+\.\d+\.\d+(-[a-z0-9]+(\.[a-z0-9]+)*)?$/; + +/** + * Validate that a value matches reverse domain notation. + */ +function isReverseDomain(value: string): boolean { + return REVERSE_DOMAIN_RE.test(value); +} + +/** + * Validate that a value matches snake_case. + */ +function isSnakeCase(value: string): boolean { + return SNAKE_CASE_RE.test(value); +} + +/** + * Validate semantic version. + */ +function isSemVer(value: string): boolean { + return SEMVER_RE.test(value); +} + +/** + * Derive namespace from package ID. + * Example: "com.acme.crm" -> "crm" + */ +function deriveNamespace(packageId: string): string { + const parts = packageId.split('.'); + return parts[parts.length - 1]; +} + +// --------------------------------------------------------------------------- +// Handler Factories +// --------------------------------------------------------------------------- + +function createListPackagesHandler(ctx: PackageToolContext): ToolHandler { + return async (args) => { + const { status, enabled } = (args ?? {}) as { + status?: string; + enabled?: boolean; + }; + + const filter: { status?: string; enabled?: boolean } = {}; + if (status) filter.status = status; + if (enabled !== undefined) filter.enabled = enabled; + + const packages = await ctx.packageRegistry.list(filter); + + const result = packages.map(pkg => ({ + id: pkg.manifest.id, + name: pkg.manifest.name, + version: pkg.manifest.version, + type: pkg.manifest.type, + status: pkg.status, + enabled: pkg.enabled, + installedAt: pkg.installedAt, + description: pkg.manifest.description, + })); + + return JSON.stringify({ + packages: result, + total: result.length, + }); + }; +} + +function createGetPackageHandler(ctx: PackageToolContext): ToolHandler { + return async (args) => { + const { packageId } = args as { packageId: string }; + + if (!packageId) { + return JSON.stringify({ error: 'packageId is required' }); + } + + const pkg = await ctx.packageRegistry.get(packageId); + + if (!pkg) { + return JSON.stringify({ error: `Package "${packageId}" not found` }); + } + + return JSON.stringify({ + id: pkg.manifest.id, + name: pkg.manifest.name, + version: pkg.manifest.version, + type: pkg.manifest.type, + status: pkg.status, + enabled: pkg.enabled, + installedAt: pkg.installedAt, + updatedAt: pkg.updatedAt, + description: pkg.manifest.description, + namespace: pkg.manifest.namespace, + dependencies: pkg.manifest.dependencies, + registeredNamespaces: pkg.registeredNamespaces, + }); + }; +} + +function createCreatePackageHandler(ctx: PackageToolContext): ToolHandler { + return async (args) => { + const { id, name, version = '1.0.0', description, namespace, type = 'application' } = args as { + id: string; + name: string; + version?: string; + description?: string; + namespace?: string; + type?: string; + }; + + // Validate required fields + if (!id || !name) { + return JSON.stringify({ error: 'Both "id" and "name" are required' }); + } + + // Validate package ID format (reverse domain notation) + if (!isReverseDomain(id)) { + return JSON.stringify({ + error: `Invalid package ID "${id}". Must be in reverse domain notation (e.g., com.acme.crm, org.mycompany.sales)`, + }); + } + + // Validate version format + if (!isSemVer(version)) { + return JSON.stringify({ + error: `Invalid version "${version}". Must be semantic version (e.g., 1.0.0, 2.1.3-beta)`, + }); + } + + // Check if package already exists + const exists = await ctx.packageRegistry.exists(id); + if (exists) { + return JSON.stringify({ error: `Package "${id}" already exists` }); + } + + // Derive or validate namespace + const derivedNamespace = namespace || deriveNamespace(id); + if (!isSnakeCase(derivedNamespace)) { + return JSON.stringify({ + error: `Invalid namespace "${derivedNamespace}". Must be snake_case (e.g., crm, sales_module)`, + }); + } + + // Build manifest + const manifest: Record = { + id, + name, + version, + type, + namespace: derivedNamespace, + ...(description ? { description } : {}), + }; + + // Install the package + const installedPackage = await ctx.packageRegistry.install(manifest); + + // Set as active package in conversation if conversation service is available + if (ctx.conversationService && ctx.conversationId) { + try { + await ctx.conversationService.updateMetadata?.(ctx.conversationId, { + activePackageId: id, + }); + } catch (err) { + // Non-critical error - package was created successfully + console.warn('Failed to set active package in conversation:', err); + } + } + + return JSON.stringify({ + packageId: installedPackage.manifest.id, + name: installedPackage.manifest.name, + version: installedPackage.manifest.version, + namespace: installedPackage.manifest.namespace, + status: installedPackage.status, + message: `Package "${name}" created successfully and set as active package`, + }); + }; +} + +function createGetActivePackageHandler(ctx: PackageToolContext): ToolHandler { + return async () => { + // If no conversation service, can't track active package + if (!ctx.conversationService || !ctx.conversationId) { + return JSON.stringify({ + activePackageId: null, + message: 'No conversation context available to track active package', + }); + } + + try { + const metadata = await ctx.conversationService.getMetadata?.(ctx.conversationId); + const activePackageId = metadata?.activePackageId as string | undefined; + + if (!activePackageId) { + return JSON.stringify({ + activePackageId: null, + message: 'No active package set. Use set_active_package or create a new package.', + }); + } + + // Get package details + const pkg = await ctx.packageRegistry.get(activePackageId); + + if (!pkg) { + return JSON.stringify({ + activePackageId, + error: `Active package "${activePackageId}" not found. It may have been uninstalled.`, + }); + } + + return JSON.stringify({ + activePackageId: pkg.manifest.id, + name: pkg.manifest.name, + version: pkg.manifest.version, + namespace: pkg.manifest.namespace, + type: pkg.manifest.type, + }); + } catch (err) { + return JSON.stringify({ + error: `Failed to get active package: ${(err as Error).message}`, + }); + } + }; +} + +function createSetActivePackageHandler(ctx: PackageToolContext): ToolHandler { + return async (args) => { + const { packageId } = args as { packageId: string }; + + if (!packageId) { + return JSON.stringify({ error: 'packageId is required' }); + } + + // Verify package exists + const pkg = await ctx.packageRegistry.get(packageId); + if (!pkg) { + return JSON.stringify({ error: `Package "${packageId}" not found` }); + } + + // If no conversation service, return error + if (!ctx.conversationService || !ctx.conversationId) { + return JSON.stringify({ + error: 'No conversation context available. Cannot set active package.', + }); + } + + try { + await ctx.conversationService.updateMetadata?.(ctx.conversationId, { + activePackageId: packageId, + }); + + return JSON.stringify({ + activePackageId: packageId, + name: pkg.manifest.name, + namespace: pkg.manifest.namespace, + message: `Active package set to "${pkg.manifest.name}"`, + }); + } catch (err) { + return JSON.stringify({ + error: `Failed to set active package: ${(err as Error).message}`, + }); + } + }; +} + +// --------------------------------------------------------------------------- +// Public Registration Helper +// --------------------------------------------------------------------------- + +/** + * Register all built-in package management tools on the given {@link ToolRegistry}. + * + * Typically called from the `ai:ready` hook after the package registry is available. + * + * @example + * ```ts + * ctx.hook('ai:ready', async (aiService) => { + * const packageRegistry = ctx.getService('packageRegistry'); + * const conversationService = ctx.getService('conversation'); + * registerPackageTools(aiService.toolRegistry, { + * packageRegistry, + * conversationService, + * }); + * }); + * ``` + */ +export function registerPackageTools( + registry: ToolRegistry, + context: PackageToolContext, +): void { + registry.register(listPackagesTool, createListPackagesHandler(context)); + registry.register(getPackageTool, createGetPackageHandler(context)); + registry.register(createPackageTool, createCreatePackageHandler(context)); + registry.register(getActivePackageTool, createGetActivePackageHandler(context)); + registry.register(setActivePackageTool, createSetActivePackageHandler(context)); +} diff --git a/packages/services/service-ai/src/tools/set-active-package.tool.ts b/packages/services/service-ai/src/tools/set-active-package.tool.ts new file mode 100644 index 000000000..53c9157ea --- /dev/null +++ b/packages/services/service-ai/src/tools/set-active-package.tool.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineTool } from '@objectstack/spec/ai'; + +/** + * set_active_package — AI Tool Metadata + * + * Sets the active package for the current conversation. + * All metadata operations will use this package context. + */ +export const setActivePackageTool = defineTool({ + name: 'set_active_package', + label: 'Set Active Package', + description: + 'Sets the active package for this conversation. All subsequent metadata creation operations ' + + '(objects, views, flows, etc.) will be associated with this package unless explicitly overridden.', + category: 'utility', + builtIn: true, + parameters: { + type: 'object', + properties: { + packageId: { + type: 'string', + description: 'Package identifier to set as active (e.g., com.acme.crm)', + }, + }, + required: ['packageId'], + additionalProperties: false, + }, +});