From 5cf475af7b1012fa8856fd736a97bd19900fa44d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:07:51 +0000 Subject: [PATCH 1/4] Initial plan From 23969c63974d426e16cb7a0486db8a0de8c457c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:16:50 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20add=20action=20target=20validation,?= =?UTF-8?q?=20execute=E2=86=92target=20migration,=20and=20cross-reference?= =?UTF-8?q?=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActionSchema: Add .refine() requiring target for url/flow/modal/api types - ActionSchema: Add .transform() auto-migrating deprecated execute→target - defineStack: Add action→flow cross-reference validation - Examples: Migrate all execute usages to target in app-todo and app-crm - Tests: Add 15 new tests covering target validation, migration, and cross-refs Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../app-crm/src/actions/contact.actions.ts | 2 +- .../app-crm/src/actions/global.actions.ts | 2 +- .../src/actions/opportunity.actions.ts | 2 +- examples/app-todo/src/actions/task.actions.ts | 12 +- packages/spec/src/stack.test.ts | 76 ++++++++++++ packages/spec/src/stack.zod.ts | 19 +++ packages/spec/src/ui/action.test.ts | 109 +++++++++++++++++- packages/spec/src/ui/action.zod.ts | 54 ++++++++- 8 files changed, 262 insertions(+), 14 deletions(-) diff --git a/examples/app-crm/src/actions/contact.actions.ts b/examples/app-crm/src/actions/contact.actions.ts index a2511b01f5..e29c946632 100644 --- a/examples/app-crm/src/actions/contact.actions.ts +++ b/examples/app-crm/src/actions/contact.actions.ts @@ -8,7 +8,7 @@ export const MarkPrimaryContactAction: Action = { label: 'Mark as Primary Contact', icon: 'star', type: 'script', - execute: 'markAsPrimaryContact', + target: 'markAsPrimaryContact', locations: ['record_header', 'list_item'], visible: 'is_primary = false', confirmText: 'Mark this contact as the primary contact for the account?', diff --git a/examples/app-crm/src/actions/global.actions.ts b/examples/app-crm/src/actions/global.actions.ts index c4e1be758d..b9f6e5b001 100644 --- a/examples/app-crm/src/actions/global.actions.ts +++ b/examples/app-crm/src/actions/global.actions.ts @@ -40,7 +40,7 @@ export const ExportToCsvAction: Action = { label: 'Export to CSV', icon: 'download', type: 'script', - execute: 'exportToCSV', + target: 'exportToCSV', locations: ['list_toolbar'], successMessage: 'Export completed!', refreshAfter: false, diff --git a/examples/app-crm/src/actions/opportunity.actions.ts b/examples/app-crm/src/actions/opportunity.actions.ts index cb6b297049..f0917b22c3 100644 --- a/examples/app-crm/src/actions/opportunity.actions.ts +++ b/examples/app-crm/src/actions/opportunity.actions.ts @@ -8,7 +8,7 @@ export const CloneOpportunityAction: Action = { label: 'Clone Opportunity', icon: 'copy', type: 'script', - execute: 'cloneRecord', + target: 'cloneRecord', locations: ['record_header', 'record_more'], successMessage: 'Opportunity cloned successfully!', refreshAfter: true, diff --git a/examples/app-todo/src/actions/task.actions.ts b/examples/app-todo/src/actions/task.actions.ts index a61c0b8bdd..f1dfa32c61 100644 --- a/examples/app-todo/src/actions/task.actions.ts +++ b/examples/app-todo/src/actions/task.actions.ts @@ -8,7 +8,7 @@ export const CompleteTaskAction: Action = { label: 'Mark Complete', icon: 'check-circle', type: 'script', - execute: 'completeTask', + target: 'completeTask', locations: ['record_header', 'list_item'], successMessage: 'Task marked as complete!', refreshAfter: true, @@ -20,7 +20,7 @@ export const StartTaskAction: Action = { label: 'Start Task', icon: 'play-circle', type: 'script', - execute: 'startTask', + target: 'startTask', locations: ['record_header', 'list_item'], successMessage: 'Task started!', refreshAfter: true, @@ -78,7 +78,7 @@ export const CloneTaskAction: Action = { label: 'Clone Task', icon: 'copy', type: 'script', - execute: 'cloneTask', + target: 'cloneTask', locations: ['record_header'], successMessage: 'Task cloned successfully!', refreshAfter: true, @@ -90,7 +90,7 @@ export const MassCompleteTasksAction: Action = { label: 'Complete Selected', icon: 'check-square', type: 'script', - execute: 'massCompleteTasks', + target: 'massCompleteTasks', locations: ['list_toolbar'], successMessage: 'Selected tasks marked as complete!', refreshAfter: true, @@ -102,7 +102,7 @@ export const DeleteCompletedAction: Action = { label: 'Delete Completed', icon: 'trash-2', type: 'script', - execute: 'deleteCompletedTasks', + target: 'deleteCompletedTasks', locations: ['list_toolbar'], successMessage: 'Completed tasks deleted!', refreshAfter: true, @@ -114,7 +114,7 @@ export const ExportToCsvAction: Action = { label: 'Export to CSV', icon: 'download', type: 'script', - execute: 'exportTasksToCSV', + target: 'exportTasksToCSV', locations: ['list_toolbar'], successMessage: 'Export completed!', refreshAfter: false, diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 8a49c310f4..0c59772781 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -961,6 +961,82 @@ describe('defineStack - Navigation Cross-Reference Validation', () => { }); }); +// ============================================================================ +// Action Cross-Reference Validation — ensures action targets resolve +// ============================================================================ + +describe('defineStack - Action Cross-Reference Validation', () => { + const baseManifest = { + id: 'com.example.test', + name: 'test-project', + version: '1.0.0', + type: 'app' as const, + }; + + it('should detect action referencing undefined flow', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + flows: [ + { name: 'existing_flow', label: 'Existing Flow', type: 'autolaunched' as const, nodes: [], edges: [] }, + ], + actions: [ + { name: 'run_flow', label: 'Run Flow', type: 'flow' as const, target: 'nonexistent_flow' }, + ], + }; + + expect(() => defineStack(config)).toThrow('cross-reference validation failed'); + expect(() => defineStack(config)).toThrow('nonexistent_flow'); + }); + + it('should pass when action references a defined flow', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + flows: [ + { name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched' as const, nodes: [], edges: [] }, + ], + actions: [ + { name: 'run_approval', label: 'Run Approval', type: 'flow' as const, target: 'approval_flow' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); + + it('should skip action flow validation when no flows are defined', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + actions: [ + { name: 'run_flow', label: 'Run Flow', type: 'flow' as const, target: 'some_flow' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); + + it('should accept script actions without cross-reference validation', () => { + const config = { + manifest: baseManifest, + objects: [ + { name: 'task', label: 'Task', fields: { title: { type: 'text' as const } } }, + ], + actions: [ + { name: 'approve_task', label: 'Approve', type: 'script' as const, target: 'approveTask' }, + ], + }; + + expect(() => defineStack(config)).not.toThrow(); + }); +}); + // ============================================================================ // Example-Level Strict Validation — mirrors examples/app-todo & examples/app-crm // ============================================================================ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index c008664342..fa19f300ab 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -400,6 +400,25 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } } + // Validate action → flow/target cross-references + if (config.actions) { + const flowNames = new Set(); + if (config.flows) { + for (const flow of config.flows) { + flowNames.add(flow.name); + } + } + + for (const action of config.actions) { + // Validate flow-type actions reference a defined flow + if (action.type === 'flow' && action.target && flowNames.size > 0 && !flowNames.has(action.target)) { + errors.push( + `Action '${action.name}' references flow '${action.target}' which is not defined in flows.`, + ); + } + } + } + return errors; } diff --git a/packages/spec/src/ui/action.test.ts b/packages/spec/src/ui/action.test.ts index fa3444d590..937e66ff82 100644 --- a/packages/spec/src/ui/action.test.ts +++ b/packages/spec/src/ui/action.test.ts @@ -77,7 +77,7 @@ describe('ActionSchema', () => { }); describe('Action Types', () => { - it('should accept all action types', () => { + it('should accept all action types with target', () => { const types = ['script', 'url', 'modal', 'flow', 'api'] as const; types.forEach(type => { @@ -85,11 +85,31 @@ describe('ActionSchema', () => { name: 'test_action', label: 'Test', type, + target: 'test_handler', }; expect(() => ActionSchema.parse(action)).not.toThrow(); }); }); + it('should accept script type without target', () => { + expect(() => ActionSchema.parse({ + name: 'test_action', + label: 'Test', + type: 'script', + })).not.toThrow(); + }); + + it('should reject url/flow/modal/api types without target', () => { + const targetRequiredTypes = ['url', 'flow', 'modal', 'api'] as const; + targetRequiredTypes.forEach(type => { + expect(() => ActionSchema.parse({ + name: 'test_action', + label: 'Test', + type, + })).toThrow(/target/); + }); + }); + it('should default to script type', () => { const action = { name: 'custom_action', @@ -294,6 +314,7 @@ describe('ActionSchema', () => { label: 'Transfer Case', icon: 'arrow-right', type: 'modal', + target: 'transfer_case_modal', locations: ['record_more'], params: [ { @@ -561,3 +582,89 @@ describe('ActionSchema - variant', () => { expect(result.confirmText).toBe('Are you sure?'); }); }); + +// ============================================================================ +// Protocol Improvement Tests: execute → target migration & target validation +// ============================================================================ + +describe('ActionSchema - execute → target migration', () => { + it('should auto-migrate execute to target when target is not set', () => { + const result = ActionSchema.parse({ + name: 'legacy_action', + label: 'Legacy', + type: 'script', + execute: 'legacyHandler', + }); + expect(result.target).toBe('legacyHandler'); + }); + + it('should preserve target over execute when both are set', () => { + const result = ActionSchema.parse({ + name: 'both_fields', + label: 'Both', + type: 'script', + target: 'preferredHandler', + execute: 'legacyHandler', + }); + expect(result.target).toBe('preferredHandler'); + }); + + it('should allow script type without target or execute', () => { + expect(() => ActionSchema.parse({ + name: 'inline_script', + label: 'Inline', + type: 'script', + })).not.toThrow(); + }); +}); + +describe('ActionSchema - target required for non-script types', () => { + it('should require target for url type', () => { + expect(() => ActionSchema.parse({ + name: 'url_action', + label: 'Open URL', + type: 'url', + })).toThrow(/target/); + }); + + it('should require target for flow type', () => { + expect(() => ActionSchema.parse({ + name: 'flow_action', + label: 'Run Flow', + type: 'flow', + })).toThrow(/target/); + }); + + it('should require target for modal type', () => { + expect(() => ActionSchema.parse({ + name: 'modal_action', + label: 'Open Modal', + type: 'modal', + })).toThrow(/target/); + }); + + it('should require target for api type', () => { + expect(() => ActionSchema.parse({ + name: 'api_action', + label: 'Call API', + type: 'api', + })).toThrow(/target/); + }); + + it('should accept non-script types when target is provided', () => { + expect(() => ActionSchema.parse({ name: 'url_ok', label: 'URL', type: 'url', target: 'https://example.com' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'flow_ok', label: 'Flow', type: 'flow', target: 'my_flow' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'modal_ok', label: 'Modal', type: 'modal', target: 'my_modal' })).not.toThrow(); + expect(() => ActionSchema.parse({ name: 'api_ok', label: 'API', type: 'api', target: '/api/endpoint' })).not.toThrow(); + }); + + it('should accept non-script types when execute is provided (auto-migrated)', () => { + const result = ActionSchema.parse({ + name: 'flow_legacy', + label: 'Flow Legacy', + type: 'flow', + execute: 'my_flow', + }); + expect(result.target).toBe('my_flow'); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 7f394080bb..b0f75af29c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -17,12 +17,35 @@ export const ActionParamSchema = z.object({ options: z.array(z.object({ label: I18nLabelSchema, value: z.string() })).optional(), }); +/** + * Action type enum values. + */ +export const ActionType = z.enum(['script', 'url', 'modal', 'flow', 'api']); + +/** + * Action types that require a `target` field. + * These types reference an external resource (URL, flow, modal, or API endpoint) + * and cannot function without a target binding. + */ +const TARGET_REQUIRED_TYPES: readonly string[] = ['url', 'flow', 'modal', 'api']; + /** * Action Schema * * **NAMING CONVENTION:** * Action names are machine identifiers used in code and must be lowercase snake_case. * + * **TARGET BINDING:** + * The `target` field is the canonical way to bind an action to its handler. + * - `type: 'script'` — `target` is recommended (references a script/function name). + * - `type: 'url'` — `target` is **required** (the URL to navigate to). + * - `type: 'flow'` — `target` is **required** (the flow name to invoke). + * - `type: 'modal'` — `target` is **required** (the modal/page name to open). + * - `type: 'api'` — `target` is **required** (the API endpoint to call). + * + * The `execute` field is **deprecated** and will be removed in a future version. + * If `execute` is provided without `target`, it is automatically migrated to `target`. + * * @example Good action names * - 'on_close_deal' * - 'send_welcome_email' @@ -67,11 +90,19 @@ export const ActionSchema = z.object({ ]).optional().describe('Visual component override'), /** What type of interaction? */ - type: z.enum(['script', 'url', 'modal', 'flow', 'api']).default('script').describe('Action functionality type'), + type: ActionType.default('script').describe('Action functionality type'), - /** Payload / Target */ - target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint'), // For URL/Flow types - execute: z.string().optional().describe('Legacy execution logic'), + /** + * Payload / Target — the canonical binding for the action handler. + * Required for url, flow, modal, and api types. + * Recommended for script type. + */ + target: z.string().optional().describe('URL, Script Name, Flow ID, or API Endpoint'), + + /** + * @deprecated Use `target` instead. This field is auto-migrated to `target` during parsing. + */ + execute: z.string().optional().describe('@deprecated — Use target instead. Auto-migrated to target during parsing.'), /** User Input Requirements */ params: z.array(ActionParamSchema).optional().describe('Input parameters required from user'), @@ -99,6 +130,21 @@ export const ActionSchema = z.object({ /** ARIA accessibility attributes */ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), +}).transform((data) => { + // Auto-migrate deprecated `execute` → `target` for backward compatibility + if (data.execute && !data.target) { + return { ...data, target: data.execute }; + } + return data; +}).refine((data) => { + // Require `target` for types that reference an external resource + if (TARGET_REQUIRED_TYPES.includes(data.type) && !data.target) { + return false; + } + return true; +}, { + message: "Action 'target' is required when type is 'url', 'flow', 'modal', or 'api'.", + path: ['target'], }); export type Action = z.infer; From c4150d95a2208752b764c12fe5d7405b2ae9f5a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:18:37 +0000 Subject: [PATCH 3/4] refactor: address code review feedback - derive TARGET_REQUIRED_TYPES from ActionType, add docs for skip behavior Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/stack.zod.ts | 3 +++ packages/spec/src/ui/action.zod.ts | 7 +++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index fa19f300ab..ccbaa5febd 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -401,6 +401,9 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { } // Validate action → flow/target cross-references + // Note: When no flows are defined (flowNames.size === 0), flow-type action targets + // are not validated because the referenced flow may be provided by a plugin. + // This is consistent with dashboard/page/report validation in navigation. if (config.actions) { const flowNames = new Set(); if (config.flows) { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index b0f75af29c..3188e8847e 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -24,10 +24,13 @@ export const ActionType = z.enum(['script', 'url', 'modal', 'flow', 'api']); /** * Action types that require a `target` field. + * Derived from ActionType, excluding 'script' which allows inline handlers. * These types reference an external resource (URL, flow, modal, or API endpoint) * and cannot function without a target binding. */ -const TARGET_REQUIRED_TYPES: readonly string[] = ['url', 'flow', 'modal', 'api']; +const TARGET_REQUIRED_TYPES = new Set( + ActionType.options.filter((t): t is Exclude => t !== 'script'), +); /** * Action Schema @@ -138,7 +141,7 @@ export const ActionSchema = z.object({ return data; }).refine((data) => { // Require `target` for types that reference an external resource - if (TARGET_REQUIRED_TYPES.includes(data.type) && !data.target) { + if (TARGET_REQUIRED_TYPES.has(data.type) && !data.target) { return false; } return true; From e102ca409686f871b46c42cdf5dc45ba9ecf1f77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 13:31:16 +0000 Subject: [PATCH 4/4] fix: use ReadonlySet for TARGET_REQUIRED_TYPES to fix DTS build error The Set narrowed type Exclude was incompatible with Set.has() receiving the full ActionType union in strict TypeScript DTS emit. Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/ui/action.zod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index 3188e8847e..3e21b11d1c 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -28,8 +28,8 @@ export const ActionType = z.enum(['script', 'url', 'modal', 'flow', 'api']); * These types reference an external resource (URL, flow, modal, or API endpoint) * and cannot function without a target binding. */ -const TARGET_REQUIRED_TYPES = new Set( - ActionType.options.filter((t): t is Exclude => t !== 'script'), +const TARGET_REQUIRED_TYPES: ReadonlySet = new Set( + ActionType.options.filter((t) => t !== 'script'), ); /**