diff --git a/.cursor/skills/processmaker-migration/SKILL.md b/.cursor/skills/processmaker-migration/SKILL.md new file mode 100644 index 0000000000..02ef834ca5 --- /dev/null +++ b/.cursor/skills/processmaker-migration/SKILL.md @@ -0,0 +1,128 @@ +--- +name: processmaker-migration +description: >- + Orchestrates ProcessMaker MCP workflows for PM3→PM4 migration, greenfield + process design, and PM4 process improvement. Use when the user mentions + ProcessMaker migration, pm3-migration-reader, processmaker-agent, MCP tools, + export_process_bundle, apply_migration_bundle, pro_uid, or migrating/designing + BPMN processes in PM4. +--- + +# ProcessMaker MCP Agent + +## Core rules + +1. **Never write to PM3/PM4 directly.** Use MCP tools only (`pm3-migration-reader`, `processmaker-agent`). +2. **Ask before writes.** Confirm with the user before `apply_migration_bundle`, `apply_process_design`, `create_process`, `update_process_bpmn`, or any tool that persists data. +3. **Start with compatibility.** Call `get_mcp_capabilities` on PM4 when starting a session or after a PM4 upgrade. If `platform_drift_detected` is true, report `platform_drift[]` before proceeding. +4. **Summarize outcomes.** Always return `process_id`, `validation`, `gap_report`, and a short manual checklist after migration or design. + +Technical reference (architecture, file paths, tests): [reference.md](reference.md) and repo root `MCPReadme.md`. + +## MCP servers + +| Server | Namespace | Role | +|--------|-----------|------| +| `pm3-migration-reader` | PM3 | Export bundles, inventory, point lookups | +| `processmaker-agent` | PM4 | Import, design, analyze, fix (`migration-writer` is alias) | + +Requires Cursor **Agent** mode and both servers green in Settings → MCP. + +## Mode selection + +| User goal | PM3 tools | PM4 tools | +|-----------|-----------|-----------| +| Migrate PM3→PM4 | Yes | Yes | +| Create new process | No | Yes | +| Improve existing PM4 | No | Yes | + +## Mode 1 — PM3→PM4 migration + +### Tool sequence + +``` +list_processes (PM3) + → get_migration_inventory (PM3) + → export_process_bundle (PM3) + → apply_migration_bundle (PM4) # user must confirm + → validate_process (PM4) # included in finalize; report anyway +``` + +### Agent behavior + +- Resolve process by name with `list_processes` + `filter_name`; confirm `pro_uid` with user. +- Present inventory risks from `migration_risks` before export. +- **Subprocesses:** migrate child processes first; pass `subprocess_map` with child PM4 `process_id`. +- **ABE:** if bundle has `abe_configurations`, ask for `email_server_map` (PM3 SMTP UID → PM4 email server ID) when not inferable. +- **Do not** claim users or effective task assignments were migrated — they are manual in PM4. +- One `pro_uid` per migration run; validate before starting the next process. + +### Resume / retry + +| Situation | Action | +|-----------|--------| +| Import interrupted (`in_progress` / `failed` log) | `get_migration_log` → `resume_migration_bundle` or `apply_migration_bundle` with `resume: true` | +| Error "Use resume=true" | Same as above | +| Log already `completed`, user wants another attempt | `apply_migration_bundle` with `fresh: true` (new PM4 process; old artifacts may remain) | +| Error "Use fresh=true" | Use `fresh: true` | + +### Final report template + +``` +process_id: +validation.valid: +gap_report.ready: (score: ) +linked_screens / scripts: +Manual steps: +1. Open process in PM4 designer — verify diagram canvas +2. Configure task assignments (users/groups) +3. Run New Request test case +4. Address gap_report.gaps[] if ready is false +``` + +## Mode 2 — Greenfield design + +Prefer **`apply_process_design`** with a single plan when the user describes a full flow. + +Alternative step-by-step: `create_process` → `create_screen_from_fields` → `link_screen_to_task` → `validate_process`. + +Always confirm before the first write. End with `validate_process` + `analyze_process`. + +## Mode 3 — Process improvement + +``` +analyze_process → present findings → user confirms fixes + → link_screen_to_task | create_screen_from_fields | add_bpmn_* | update_process_bpmn + → analyze_process again +``` + +Do not apply fixes silently when severity is `error` or `warning`. + +## Key terms + +| Term | Meaning | +|------|---------| +| `pro_uid` | PM3 process UUID; migration log key | +| `process_id` | PM4 integer ID | +| `bundle` | Output of `export_process_bundle` | +| `task_element_map` | `tas_uid` → BPMN `node_X`; drives screen links | +| `gap_report.ready` | `true` = no critical gaps detected | + +## Common errors (agent response) + +| Symptom | Fix | +|---------|-----| +| Screen not linked in gap_report | `link_screen_to_task` with `element_id` from gap or inventory | +| Subprocess missing callActivity | Migrate child first; add `subprocess_map` | +| Empty BPMN canvas | Re-export from PM3; ensure bundle has `bpmndi` | +| PM4 tools incomplete in Cursor | Clear `~/.cursor/projects//mcps/user-processmaker-agent/` and restart MCP | +| `bpmnElement` XSD error | Restart PM4 MCP server (stale code) | + +## What MCP does not do + +- Start cases / New Request execution +- Sync PM3 users or groups +- Auto-layout when **adding** new BPMN nodes in PM4 (migration preserves PM3 canvas) +- Full UI parity with ProcessMaker designer + +For tool parameters and JSON examples, see `MCPReadme.md` § Security (dev-only) before production use. diff --git a/.cursor/skills/processmaker-migration/reference.md b/.cursor/skills/processmaker-migration/reference.md new file mode 100644 index 0000000000..4b73e25c62 --- /dev/null +++ b/.cursor/skills/processmaker-migration/reference.md @@ -0,0 +1,57 @@ +# ProcessMaker MCP — Agent reference + +Full documentation: [`MCPReadme.md`](../../../MCPReadme.md) (repo root). + +## Tool index + +### PM3 (`pm3-migration-reader`) + +| Tool | Purpose | +|------|---------| +| `list_processes` | Find processes (`filter_name`, `start`, `limit`) | +| `get_migration_inventory` | Pre-migration summary + risks | +| `export_process_bundle` | Full bundle for `apply_migration_bundle` | +| `get_dynaform`, `get_trigger`, `get_task_steps`, `get_task_assignments` | Point lookups | +| `get_process_variables`, `get_report_tables`, `get_abe_configurations`, `get_stylesheets`, `get_workflow_metadata` | Point lookups | + +### PM4 (`processmaker-agent`) + +| Category | Tools | +|----------|-------| +| Compatibility | `get_mcp_capabilities` | +| Migration | `apply_migration_bundle`, `resume_migration_bundle`, `get_migration_log`, `list_migration_logs`, `get_migration_gap_report`, `create_screen_from_dynaform`, `link_migration_assets`, `apply_task_assignments`, `apply_abe_configuration`, `create_collection_from_report_table` | +| Read | `list_processes`, `get_process`, `get_process_inventory`, `get_process_bpmn`, `list_screens`, `get_screen`, `list_scripts`, `get_script`, `analyze_process` | +| Design | `apply_process_design`, `create_process`, `update_process`, `create_screen`, `create_screen_from_fields`, `create_script`, `add_bpmn_user_task`, `add_bpmn_sequence_flow`, `add_bpmn_exclusive_gateway`, `apply_process_variables` | +| Fix | `validate_process`, `update_process_bpmn`, `link_screen_to_task`, `link_script_to_task` | + +## Bundle optional fields (PM4 import) + +```json +{ + "subprocess_map": { "CHILD-UID-PM3": "42" }, + "subprocess_start_event_map": { "CHILD-UID-PM3": "node_2" }, + "email_server_map": { "SMTP-UID-PM3": "1" } +} +``` + +## Migration import steps (log checkpoints) + +`create_process` → `create_scripts` → `create_screens` → `link_assets` → `apply_assignments` → `apply_variables` → `bpmn_enhance` → `store_metadata` → `web_entry` → `step_triggers` → `collections` → `abe` → `finalize` + +Log path: `storage/app/migration-logs/{pro_uid}.json` + +## Cursor MCP config + +See `MCPReadme.md` § Cursor setup. PM3 launcher: `processmaker3/workflow/engine/bin/mcp-migration-reader.sh`. + +PM3 optional env: `PM_WORKSPACE`, `PM3_PATH_DATA`, `PM3_MCP_DB_HOST`, `PM3_MCP_DB_PORT`, `PM3_PHP`. + +## Tests + +```bash +# PM4 +./vendor/bin/phpunit tests/unit/Mcp/ tests/Feature/Mcp/Migration/WriterTest.php + +# PM3 +./vendor/bin/phpunit tests/unit/ProcessMaker/Mcp/Migration/ +``` diff --git a/MCPReadme.md b/MCPReadme.md new file mode 100644 index 0000000000..91b5afecd3 --- /dev/null +++ b/MCPReadme.md @@ -0,0 +1,704 @@ +# ProcessMaker MCP — Reference Guide + +Agents (Cursor, FlowGenie, etc.) can **migrate PM3→PM4**, **create new processes**, and **analyze/improve** existing ones using MCP servers. + +You do not need PHP or BPMN to get started: write a prompt and the agent invokes tools for you. The **LLM does not write to the database**; all persistence goes through `Reader.php` (PM3) or `Writer.php` / `Designer.php` (PM4). + +**Cursor orchestration:** see [`.cursor/skills/processmaker-migration/SKILL.md`](.cursor/skills/processmaker-migration/SKILL.md) for agent workflow rules (this file remains the full technical reference). + +--- + +## Table of Contents + +1. [Glossary](#glossary) +2. [Three usage modes](#three-usage-modes) +3. [Cursor setup](#cursor-setup) +4. [First test](#first-test) +5. [Post-upgrade compatibility](#post-upgrade-compatibility) +6. [Mode 1 — PM3→PM4 migration](#mode-1--pm3pm4-migration) +7. [Bundle and import steps](#bundle-and-import-steps) +8. [Log, resume, and fresh](#log-resume-and-fresh) +9. [Detailed examples](#detailed-examples) +10. [Mode 2 — Greenfield design](#mode-2--greenfield-design) +11. [Mode 3 — Process improvement](#mode-3--process-improvement) +12. [MCP tools](#mcp-tools) +13. [What migrates / what does not](#what-migrates--what-does-not) +14. [Post-import checklist](#post-import-checklist) +15. [Common errors](#common-errors) +16. [Debug and architecture](#debug-and-architecture) +17. [Repository files](#repository-files) +18. [Tests](#tests) +19. [Limitations](#limitations) +20. [Security (dev-only)](#security-dev-only) + +--- + +## Glossary + +| Term | Description | +|------|-------------| +| **Process** | Workflow (tasks, decisions, forms). | +| **PM3 / PM4** | ProcessMaker version 3 (source) and 4 (target). | +| **pro_uid** | Process UUID in PM3. Key for export and migration log. | +| **process_id** | Integer process ID in PM4 (e.g. `87`). Shown in Processes UI. | +| **bundle** | JSON from `export_process_bundle`; input to `apply_migration_bundle`. | +| **workspace** | PM3 instance (default `workflow`). | +| **task_element_map** | Map `tas_uid` PM3 → BPMN node (`node_X`). Required to link screens. | +| **pm3_uid / pm4_id** | Mapping trigger/dynaform PM3 → script/screen PM4. | +| **gap_report** | Post-import gaps; `ready: true` = nothing critical detected. | +| **migration_log** | JSON at `storage/app/migration-logs/{pro_uid}.json`; enables resume. | +| **Screen** | Form shown in a PM4 task. | +| **Script** | Automated PHP in PM4. | +| **BPMN** | Diagram XML (start, tasks, end + `bpmndi` for canvas). | +| **MCP tool** | Function invoked by the agent (e.g. `list_processes`). | + +--- + +## Three usage modes + +| Mode | PM3 server | PM4 server | Goal | +|------|------------|------------|------| +| Migration | `pm3-migration-reader` | `processmaker-agent` | Copy PM3 process to PM4 | +| Design | — | `processmaker-agent` | Create process from scratch | +| Improve | — | `processmaker-agent` | Analyze and fix PM4 process | + +`migration-writer` is an **alias** of `processmaker-agent` (same **34 tools**). + +### Common flow + +``` +User → Cursor Agent → MCP tool (JSON) → PM core (DB) + ← summary + process_id ← structured JSON ← +``` + +**Migration:** `list_processes` → `get_migration_inventory` → `export_process_bundle` → `apply_migration_bundle` → `validate_process` + +**Design:** `apply_process_design` or individual tools + +**Improve:** `analyze_process` → fix with tools → verification `analyze_process` + +### Classic vs BPMN in PM3 + +| `project_type` | Export behavior | +|----------------|-----------------| +| `classic` | BPMN built from routes/gateways + `TAS_POS*` positions. | +| `bpmn` | Native PM3 BPMN + `BPMN_BOUND` coordinates and `FLO_STATE` waypoints. | + +The MCP flow is the same: export → apply. The difference is internal in `BpmnXmlExporter`. + +### Order when migrating multiple processes + +1. **Child subprocesses first**, then parent (`subprocess_map` needs the child’s PM4 `process_id`). +2. **One `pro_uid` at a time** — each has its own log. +3. Validate in PM4 before migrating the next one. + +--- + +## Cursor setup + +**Requirements:** PM3 and PM4 running (local or Docker). Chat mode **Agent** (not Ask). + +Register **two servers** in `.cursor/mcp.json` (replace ``, ``, and `` with your local paths): + +```json +{ + "mcpServers": { + "pm3-migration-reader": { + "command": "/workflow/engine/bin/mcp-migration-reader.sh", + "args": ["workflow"], + "env": { "PM_WORKSPACE": "workflow" } + }, + "processmaker-agent": { + "command": "", + "args": [ + "/artisan", + "mcp:start", + "processmaker-agent" + ], + "cwd": "" + } + } +} +``` + +Example: `` = `/srv/http/processmaker3`, `` = `/srv/http/processmaker4`, `` = `php` or `/usr/local/bin/php`. + +**PM3:** `mcp-migration-reader.sh` → `mcp-migration-reader.php` (stdio JSON-RPC, no HTTP). + +**PM4:** `php artisan mcp:start processmaker-agent` (stdio). Optional HTTP: `/mcp/processmaker-agent`. + +### PM3 environment variables (optional) + +| Variable | When | +|----------|------| +| `PM_WORKSPACE` | PM3 workspace (default `workflow`) | +| `PM3_PATH_DATA` | When `paths_installed.php` points to a missing host path | +| `PM3_MCP_DB_HOST` | When `db.php` uses Docker hostname (`db`) | +| `PM3_MCP_DB_PORT` | DB port on host (default `3307`) | +| `PM3_PHP` | PHP binary (default `php`) | + +### Enable + +1. Save `mcp.json`. +2. Toggle MCP off/on in Cursor. If tools are missing: delete `~/.cursor/projects//mcps/user-processmaker-agent/` and restart. +3. Confirm both servers are green in Settings → MCP. + +--- + +## First test + +``` +Run get_mcp_capabilities and tell me mcp_version and whether platform_drift_detected is false. +Then list the first 5 PM4 processes with list_processes. +``` + +Expected: version `1.2.0`, drift `false`, list with `id` and `name`. + +PM3 test: `"list PM3 processes named test"` → `list_processes`. + +--- + +## Post-upgrade compatibility + +``` +Run get_mcp_capabilities and tell me if platform_drift_detected is true. +``` + +| Field | Meaning | +|-------|---------| +| `platform_drift_detected: false` | MCP compatible with current PM4 core | +| `platform_drift_detected: true` | PM4 changed; review `platform_drift[]` and update MCP | +| `supported_features` | Implemented capabilities | +| `not_supported_by_mcp` | Known limits | + +CI test: `tests/unit/Mcp/McpSupportTest.php` + +--- + +## Mode 1 — PM3→PM4 migration + +### Prompt (copy and adapt) + +``` +Migrate PM3 process "Invoice Approval" to PM4. + +Rules: +- Step by step; ask "Shall we continue?" before each important action. +- PM3: list_processes → get_migration_inventory → export_process_bundle +- PM4: apply_migration_bundle (only with my explicit OK) +- If interrupted: get_migration_log + resume_migration_bundle +- Do not migrate users or effective task assignments. +- If subprocesses exist, migrate the child first or pass subprocess_map. +- If ABE exists, ask for email_server_map if you cannot infer it. +- At the end: PM4 process_id, gap_report, warnings, and manual checklist. +``` + +### Typical conversation + +``` +You: Migrate PM3 process "Invoice Approval" to PM4. + +AI: Found 1 process: PRO_UID f4b2c8e0-..., title "Invoice Approval". Continue? + +You: Yes. + +AI: Inventory: 4 tasks, 2 dynaforms, 3 triggers. Risk: PM3 assignments (manual in PM4). + Export the bundle? + +You: Yes. + +AI: Bundle ready (BPMN + diagram + screens + scripts). Import to PM4? + +You: Yes, import. + +AI: Done: process_id 87, validation.valid true, gap_report.ready true. + Review assignments in Processes → 87. +``` + +### Conversational flow + +| Step | Tool | Your action | +|------|------|-------------| +| 1 | `list_processes` | Confirm `pro_uid` | +| 2 | `get_migration_inventory` | Continue or cancel based on risks | +| 3 | `export_process_bundle` | OK to import | +| 4 | `apply_migration_bundle` | Review `process_id`, `validation`, `gap_report` | +| 5 | (optional) gaps | Fix with PM4 tools or manually | +| 6 | PM4 browser | Post-import checklist | + +--- + +## Bundle and import steps + +### Bundle sections + +| Section | Content | +|---------|---------| +| `source` | `pro_uid`, `title`, `project_type` | +| `bpmn.xml` | Diagram + `bpmndi:BPMNDiagram` (PM3 layout) | +| `tasks`, `steps`, `task_element_map` | Task ↔ screen/script ↔ BPMN node links | +| `triggers` | `TRI_WEBBOT` with `@@var` syntax | +| `dynaforms`, `stylesheets` | Forms and CSS | +| `variables`, `task_assignments` | Metadata (effective assignment is manual) | +| `timer_events`, `sub_processes`, `web_entries` | Timers, subprocesses, web entry | +| `report_tables`, `abe_configurations` | Collections, ABE | +| `migration_risks` | Inventory warnings | + +**Key relationship:** `steps` (tas_uid + dyn_uid) + `task_element_map` (tas_uid → node_X) → `link_assets` writes `pm:screenRef` on the BPMN node. + +### Optional bundle fields (PM4) + +```json +"subprocess_map": { "CHILD-UID-PM3": "42" }, +"subprocess_start_event_map": { "CHILD-UID-PM3": "node_2" }, +"email_server_map": { "SMTP-UID-PM3": "1" } +``` + +- **subprocess_map:** Numeric ID of the already-migrated child process in PM4. +- **email_server_map:** Email Server ID in PM4 (Settings → Email Servers). + +### `runMigrationBundle` order (13 steps) + +| Log step | Action | +|----------|--------| +| `create_process` | PM4 process + sanitized BPMN | +| `create_scripts` | Translated scripts (`TriggerTranslator`) | +| `create_screens` | Screens from dynaforms (`DynaformConverter`) | +| `link_assets` | `screenRef` / `scriptRef` on BPMN | +| `apply_assignments` | Assignment metadata (no PM4 users) | +| `apply_variables` | Variables in `process.properties` | +| `bpmn_enhance` | Boundary timers, callActivity | +| `store_metadata` | document_steps, timers, subprocesses | +| `web_entry` | Start event config | +| `step_triggers` | Before/after triggers on BPMN | +| `collections` | Report tables → Collection | +| `abe` | Actions By Email | +| `finalize` | `validate_process` + `gap_report` | + +On disk: `storage/app/migration-logs/{pro_uid}.json` + +--- + +## Log, resume, and fresh + +### Log structure + +- `status`: `in_progress` | `failed` | `completed` +- `process_id`, `completed_steps`, `pending_steps` +- `artifacts`: scripts/screens/collections with `pm3_uid` → `pm4_id` + +### Resume interrupted import + +``` +1. get_migration_log({ "pro_uid": "..." }) +2. resume_migration_bundle({ "bundle": { ...same export... } }) + — or — + apply_migration_bundle({ "bundle": {...}, "resume": true }) +``` + +- Reuses `process_id` and artifacts already created (by `pm3_uid` in log). +- Bundle can be a fresh re-export if `pro_uid` matches. + +### `fresh: true` + +- Deletes **only** the log JSON. +- Creates a **new** process in PM4 (new `process_id`). +- **Does not delete** processes/scripts/screens from the previous attempt → manual cleanup if needed. + +### Rules + +| Situation | Action | +|-----------|--------| +| Log `in_progress` / `failed` | `resume: true` (not normal apply) | +| Log `completed` | `fresh: true` to retry with a new log | +| Error "Use resume=true" | Partial log → resume | +| Error "Use fresh=true" | `pro_uid` already completed → fresh for another attempt | + +--- + +## Detailed examples + +### A — Export and import (minimal requests) + +**PM3 — list_processes** +```json +{ "filter_name": "Invoice Approval", "start": 0, "limit": 10 } +``` + +**PM3 — export_process_bundle** +```json +{ "pro_uid": "f4b2c8e0-1a3d-4f5e-9b6c-7d8e9f0a1b2c" } +``` + +**PM4 — apply_migration_bundle** +```json +{ "bundle": { "...full export object..." }, "fresh": true } +``` + +### B — Typical import response + +```json +{ + "process_id": 87, + "scripts": [{ "pm3_uid": "c3d4...", "pm4_id": 15, "title": "Set Approved" }], + "screens": [{ "pm3_uid": "d4e5...", "pm4_id": 22, "title": "Invoice Form" }], + "linked_screens": [{ + "tas_uid": "task-review-uid", + "element_id": "node_3", + "screen_id": 22 + }], + "validation": { "valid": true, "errors": [] }, + "gap_report": { "ready": true, "gaps": [], "score": 100 }, + "migration_log": { "status": "completed", "process_id": 87 } +} +``` + +### C — PM3 trigger → PM4 script + +Input: +```php + +``` + +Output (`TriggerTranslator`): +```php +user_id) { + Auth::setUser(User::findOrFail($process->user_id)); + + return; + } + + Auth::setUser(User::firstOrFail()); + } + + /** + * Features the MCP layer implements today. Update this list when adding tools. + * + * @return array + */ + public static function supportedFeatures(): array + { + return [ + 'migration_pm3_bundle', + 'migration_log_resume', + 'process_read', + 'process_analyze', + 'process_design_plan', + 'process_bpmn_user_task', + 'process_bpmn_sequence_flow', + 'process_bpmn_gateway', + 'screen_from_fields', + 'screen_config_validation', + 'platform_writer', + 'process_variables', + 'screen_script_link', + 'bpmn_validate', + ]; + } + + /** + * Platform integration points checked at runtime. If a check fails, PM core changed and MCP may need updates. + * + * @return array + */ + public static function platformChecks(): array + { + $checks = []; + + $checks[] = self::classCheck('process_model', Process::class, ['rules', 'getDefinitions']); + $checks[] = self::classCheck('screen_model', \ProcessMaker\Models\Screen::class, ['rules']); + $checks[] = self::classCheck('script_model', \ProcessMaker\Models\Script::class, ['rules']); + $checks[] = self::classCheck('bpmn_document', \ProcessMaker\Nayra\Storage\BpmnDocument::class, ['validateBPMNSchema']); + $checks[] = self::classCheck('fix_bpmn_schema', \ProcessMaker\Nayra\Services\FixBpmnSchemaService::class, ['fix']); + $checks[] = self::classCheck('bpmn_validation_rule', \ProcessMaker\Rules\BPMNValidation::class, ['passes']); + + $xsdPath = public_path('definitions/ProcessMaker.xsd'); + $checks[] = [ + 'id' => 'bpmn_xsd', + 'label' => 'BPMN XSD schema file', + 'ok' => is_readable($xsdPath), + 'detail' => is_readable($xsdPath) ? $xsdPath : 'Missing: public/definitions/ProcessMaker.xsd', + ]; + + $template = database_path('processes/templates/SingleTask.bpmn'); + $checks[] = [ + 'id' => 'bpmn_template_single_task', + 'label' => 'SingleTask BPMN template', + 'ok' => is_readable($template), + 'detail' => is_readable($template) ? $template : 'Missing SingleTask.bpmn template', + ]; + + $checks[] = [ + 'id' => 'collections_package', + 'label' => 'Collections plugin (optional, for report tables)', + 'ok' => class_exists(\ProcessMaker\Plugins\Collections\Models\Collection::class), + 'detail' => class_exists(\ProcessMaker\Plugins\Collections\Models\Collection::class) + ? 'Available' + : 'Not installed; collections migration step will warn', + ]; + + $checks[] = [ + 'id' => 'laravel_mcp', + 'label' => 'Laravel MCP server', + 'ok' => class_exists(\Laravel\Mcp\Server\Tool::class), + 'detail' => class_exists(\Laravel\Mcp\Server\Tool::class) ? 'Available' : 'laravel/mcp not installed', + ]; + + $checks[] = [ + 'id' => 'screen_control_catalog', + 'label' => 'MCP screen control templates (PM4 shipped screens)', + 'ok' => ScreenControlCatalog::missingComponents() === [], + 'detail' => ScreenControlCatalog::missingComponents() === [] + ? 'All required control templates indexed' + : 'Missing templates: ' . implode(', ', ScreenControlCatalog::missingComponents()), + ]; + + $checks[] = self::classCheck('platform_writer', PlatformWriter::class, [ + 'createProcess', + 'createScreen', + 'updateProcessBpmn', + 'createScript', + ]); + $checks[] = self::classCheck('screen_config_normalizer', \ProcessMaker\Mcp\Process\ScreenConfigNormalizer::class, [ + 'normalize', + 'normalizeItem', + ]); + $checks[] = self::classCheck('import_screen_job', \ProcessMaker\Jobs\ImportScreen::class, ['handle']); + + return $checks; + } + + /** + * @param array $methods + * @return array{id: string, label: string, ok: bool, detail: string} + */ + private static function classCheck(string $id, string $class, array $methods): array + { + if (!class_exists($class)) { + return [ + 'id' => $id, + 'label' => $class, + 'ok' => false, + 'detail' => 'Class not found', + ]; + } + + $missing = []; + foreach ($methods as $method) { + if (!method_exists($class, $method)) { + $missing[] = $method; + } + } + + return [ + 'id' => $id, + 'label' => $class, + 'ok' => $missing === [], + 'detail' => $missing === [] ? 'OK' : 'Missing methods: ' . implode(', ', $missing), + ]; + } + + /** + * @return array + */ + public static function report(): array + { + $checks = self::platformChecks(); + $failed = array_values(array_filter($checks, fn (array $c): bool => !$c['ok'])); + $requiredFailed = array_values(array_filter( + $failed, + fn (array $c): bool => $c['id'] !== 'collections_package' + )); + + return [ + 'mcp_version' => self::MCP_VERSION, + 'pm_version' => self::pmVersion(), + 'supported_features' => self::supportedFeatures(), + 'platform_checks' => $checks, + 'platform_drift_detected' => $requiredFailed !== [], + 'platform_drift' => array_map(fn (array $c): array => [ + 'id' => $c['id'], + 'detail' => $c['detail'], + ], $requiredFailed), + 'optional_gaps' => array_map(fn (array $c): array => [ + 'id' => $c['id'], + 'detail' => $c['detail'], + ], array_filter($failed, fn (array $c): bool => $c['id'] === 'collections_package')), + 'not_supported_by_mcp' => [ + 'full_platform_parity' => 'MCP covers migration, basic design, and analysis — not every PM4 UI feature.', + 'user_group_sync' => 'User/group assignment is metadata only; configure in PM4 UI.', + 'runtime_case_testing' => 'Starting requests and task completion is not exposed yet.', + 'advanced_bpmn_layout' => 'PM3 canvas positions migrate via BPMNDiagram; PM4 only auto-layouts when diagram is missing.', + ], + 'screen_control_catalog' => ScreenControlCatalog::catalogReport(), + ]; + } + + private static function pmVersion(): string + { + $composer = base_path('composer.json'); + if (!is_readable($composer)) { + return 'unknown'; + } + + $data = json_decode(file_get_contents($composer), true); + + return is_array($data) ? (string) ($data['version'] ?? 'unknown') : 'unknown'; + } +} diff --git a/ProcessMaker/Mcp/Migration/BpmnDiagramInterchange.php b/ProcessMaker/Mcp/Migration/BpmnDiagramInterchange.php new file mode 100644 index 0000000000..df8af71719 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/BpmnDiagramInterchange.php @@ -0,0 +1,308 @@ +loadXML($xml) === false) { + return $xml; + } + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('bpmn', self::BPMN_NS); + + if ($xpath->query('//*[local-name()="BPMNDiagram"]')->length > 0) { + return $document->saveXML() ?: $xml; + } + + return self::appendLayout($document, $xpath) ?: $xml; + } + + /** + * Rebuild BPMNDiagram from current process elements (e.g. after MCP adds tasks/flows). + */ + public static function rebuild(string $xml): string + { + $document = new DOMDocument(); + if (@$document->loadXML($xml) === false) { + return $xml; + } + + $xpath = new DOMXPath($document); + $xpath->registerNamespace('bpmn', self::BPMN_NS); + + foreach ($xpath->query('//*[local-name()="BPMNDiagram"]') as $diagram) { + $diagram->parentNode?->removeChild($diagram); + } + + return self::appendLayout($document, $xpath) ?: $xml; + } + + private static function appendLayout(DOMDocument $document, DOMXPath $xpath): ?string + { + $processNode = $xpath->query('//bpmn:process')->item(0); + if (!($processNode instanceof DOMElement)) { + return $document->saveXML(); + } + + $processId = $processNode->getAttribute('id') ?: 'ProcessId'; + $definitions = $document->documentElement; + if (!($definitions instanceof DOMElement)) { + return $document->saveXML(); + } + + $layout = self::buildLayout($xpath); + self::appendDiagram($document, $definitions, $processId, $layout['nodes'], $layout['flows']); + + return $document->saveXML(); + } + + /** + * @param array $nodeBounds + * @param array> $flowWaypoints + */ + private static function appendDiagram( + DOMDocument $document, + DOMElement $definitions, + string $processId, + array $nodeBounds, + array $flowWaypoints = [], + ): void { + if ($nodeBounds === []) { + return; + } + + $diagram = $document->createElementNS(self::BPMNDI_NS, 'bpmndi:BPMNDiagram'); + $diagram->setAttribute('id', 'BPMNDiagram_' . $processId); + $diagram->setAttribute('name', $processId); + + $plane = $document->createElementNS(self::BPMNDI_NS, 'bpmndi:BPMNPlane'); + $plane->setAttribute('bpmnElement', $processId); + $diagram->appendChild($plane); + + foreach ($nodeBounds as $elementId => $bounds) { + $shape = $document->createElementNS(self::BPMNDI_NS, 'bpmndi:BPMNShape'); + $shape->setAttribute('id', 'BPMNShape_' . $elementId); + $shape->setAttribute('bpmnElement', $elementId); + + $dcBounds = $document->createElementNS(self::DC_NS, 'dc:Bounds'); + $dcBounds->setAttribute('x', (string) $bounds['x']); + $dcBounds->setAttribute('y', (string) $bounds['y']); + $dcBounds->setAttribute('width', (string) $bounds['width']); + $dcBounds->setAttribute('height', (string) $bounds['height']); + $shape->appendChild($dcBounds); + $plane->appendChild($shape); + } + + foreach ($flowWaypoints as $flowId => $waypoints) { + if ($waypoints === []) { + continue; + } + + $edge = $document->createElementNS(self::BPMNDI_NS, 'bpmndi:BPMNEdge'); + $edge->setAttribute('id', 'BPMNEdge_' . $flowId); + $edge->setAttribute('bpmnElement', $flowId); + + foreach ($waypoints as $point) { + $waypoint = $document->createElementNS(self::DI_NS, 'di:waypoint'); + $waypoint->setAttribute('x', (string) $point['x']); + $waypoint->setAttribute('y', (string) $point['y']); + $edge->appendChild($waypoint); + } + + $plane->appendChild($edge); + } + + $definitions->appendChild($diagram); + } + + /** + * @return array{ + * nodes: array, + * flows: array> + * } + */ + private static function buildLayout(DOMXPath $xpath): array + { + $nodes = []; + + foreach ($xpath->query('//*[local-name()="startEvent" or local-name()="endEvent" or local-name()="task" or local-name()="userTask" or local-name()="scriptTask" or local-name()="exclusiveGateway" or local-name()="parallelGateway" or local-name()="inclusiveGateway"]') as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + $id = $node->getAttribute('id'); + if ($id === '') { + continue; + } + + $nodes[$id] = self::defaultBounds($node->localName); + } + + $orderedIds = self::orderNodesByFlow($xpath, $nodes); + + $flowWaypoints = []; + foreach ($xpath->query('//*[local-name()="sequenceFlow"]') as $flow) { + if (!($flow instanceof DOMElement)) { + continue; + } + + $flowId = $flow->getAttribute('id'); + $source = $flow->getAttribute('sourceRef'); + $target = $flow->getAttribute('targetRef'); + if ($flowId === '' || $source === '' || $target === '') { + continue; + } + + $flowWaypoints[$flowId] = self::edgeWaypoints( + $nodes[$source] ?? self::defaultBounds('task'), + $nodes[$target] ?? self::defaultBounds('task'), + ); + } + + $column = 0; + foreach ($orderedIds as $elementId) { + $size = $nodes[$elementId]; + $nodes[$elementId] = [ + 'x' => 120 + ($column * 180), + 'y' => 120, + 'width' => $size['width'], + 'height' => $size['height'], + ]; + $column++; + } + + foreach ($flowWaypoints as $flowId => $points) { + $flow = $xpath->query('//*[@id="' . $flowId . '"]')->item(0); + if (!($flow instanceof DOMElement)) { + continue; + } + + $source = $flow->getAttribute('sourceRef'); + $target = $flow->getAttribute('targetRef'); + if (!isset($nodes[$source], $nodes[$target])) { + continue; + } + + $flowWaypoints[$flowId] = self::edgeWaypoints($nodes[$source], $nodes[$target]); + } + + return ['nodes' => $nodes, 'flows' => $flowWaypoints]; + } + + /** + * @param array $nodes + * @return array + */ + private static function orderNodesByFlow(DOMXPath $xpath, array $nodes): array + { + $startIds = []; + foreach ($xpath->query('//*[local-name()="startEvent"]') as $start) { + if (!($start instanceof DOMElement)) { + continue; + } + + $id = $start->getAttribute('id'); + if ($id !== '') { + $startIds[] = $id; + } + } + + $outgoing = []; + foreach ($xpath->query('//*[local-name()="sequenceFlow"]') as $flow) { + if (!($flow instanceof DOMElement)) { + continue; + } + + $source = $flow->getAttribute('sourceRef'); + $target = $flow->getAttribute('targetRef'); + if ($source !== '' && $target !== '') { + $outgoing[$source][] = $target; + } + } + + $ordered = []; + $visited = []; + $queue = $startIds; + + while ($queue !== []) { + $id = array_shift($queue); + if (isset($visited[$id])) { + continue; + } + + $visited[$id] = true; + if (isset($nodes[$id])) { + $ordered[] = $id; + } + + foreach ($outgoing[$id] ?? [] as $target) { + if (!isset($visited[$target])) { + $queue[] = $target; + } + } + } + + foreach (array_keys($nodes) as $id) { + if (!isset($visited[$id])) { + $ordered[] = $id; + } + } + + return $ordered; + } + + /** + * @param array{x:int,y:int,width:int,height:int} $source + * @param array{x:int,y:int,width:int,height:int} $target + * @return array + */ + private static function edgeWaypoints(array $source, array $target): array + { + $sourceX = $source['x'] + $source['width']; + $sourceY = $source['y'] + (int) floor($source['height'] / 2); + $targetX = $target['x']; + $targetY = $target['y'] + (int) floor($target['height'] / 2); + + return [ + ['x' => $sourceX, 'y' => $sourceY], + ['x' => $targetX, 'y' => $targetY], + ]; + } + + /** + * @return array{x:int,y:int,width:int,height:int} + */ + private static function defaultBounds(string $localName): array + { + return match ($localName) { + 'startEvent', 'endEvent' => ['x' => 0, 'y' => 0, 'width' => 36, 'height' => 36], + 'exclusiveGateway', 'parallelGateway', 'inclusiveGateway', 'complexGateway' => ['x' => 0, 'y' => 0, 'width' => 50, 'height' => 50], + default => ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 80], + }; + } +} diff --git a/ProcessMaker/Mcp/Migration/BpmnEnhancer.php b/ProcessMaker/Mcp/Migration/BpmnEnhancer.php new file mode 100644 index 0000000000..3dc173264b --- /dev/null +++ b/ProcessMaker/Mcp/Migration/BpmnEnhancer.php @@ -0,0 +1,271 @@ + $payload + * @return array{ + * applied_timers: array, + * applied_sub_processes: array, + * warnings: array + * } + */ + public function apply(Process $process, array $payload): array + { + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $taskElementMap = MigrationBpmn::buildTaskElementMap($payload, $xpath); + $subprocessMap = is_array($payload['subprocess_map'] ?? null) ? $payload['subprocess_map'] : []; + + $appliedTimers = []; + $appliedSubProcesses = []; + $warnings = []; + $modified = false; + $counter = 1; + + foreach (array_merge($payload['timer_events'] ?? [], $payload['case_schedulers'] ?? []) as $timer) { + if (!is_array($timer)) { + continue; + } + + $taskUid = $timer['act_uid'] ?? $timer['tas_uid'] ?? null; + if (!is_string($taskUid) || $taskUid === '') { + continue; + } + + $elementId = $taskElementMap[$taskUid] ?? null; + if ($elementId === null) { + $warnings[] = 'Timer on PM3 task ' . $taskUid . ' has no BPMN element mapping.'; + continue; + } + + if ($this->hasBoundaryTimer($xpath, $elementId)) { + continue; + } + + $duration = $this->buildIsoDuration($timer); + $boundaryId = 'migration_timer_' . $counter; + $endId = 'migration_timer_end_' . $counter; + $flowId = 'migration_timer_flow_' . $counter; + $counter++; + + $this->appendBoundaryTimer($definitions, $elementId, $boundaryId, $endId, $flowId, $duration); + $appliedTimers[] = [ + 'task_uid' => $taskUid, + 'element_id' => $elementId, + 'duration' => $duration, + 'boundary_id' => $boundaryId, + ]; + $modified = true; + } + + foreach ($payload['sub_processes'] ?? [] as $subProcess) { + if (!is_array($subProcess)) { + continue; + } + + $tasUid = $subProcess['tas_uid'] ?? null; + $pm3ProcessUid = $subProcess['pro_uid'] ?? null; + if (!is_string($tasUid) || $tasUid === '' || !is_string($pm3ProcessUid) || $pm3ProcessUid === '') { + continue; + } + + $pm4ProcessId = $subprocessMap[$pm3ProcessUid] ?? null; + if ($pm4ProcessId === null || $pm4ProcessId === '') { + $warnings[] = 'Sub-process ' . $pm3ProcessUid . ' requires manual subprocess_map entry.'; + continue; + } + + $elementId = $taskElementMap[$tasUid] ?? null; + if ($elementId === null) { + $warnings[] = 'Sub-process task ' . $tasUid . ' has no BPMN element mapping.'; + continue; + } + + $taskNode = $xpath->query("//*[@id='{$elementId}']")->item(0); + if (!($taskNode instanceof DOMElement)) { + $warnings[] = 'BPMN element ' . $elementId . ' not found for sub-process task ' . $tasUid . '.'; + continue; + } + + if (in_array($taskNode->localName, ['callActivity'], true)) { + continue; + } + + $this->replaceWithCallActivity( + $definitions, + $taskNode, + (string) $pm4ProcessId, + (string) ($payload['subprocess_start_event_map'][$pm3ProcessUid] ?? 'node_2') + ); + + $appliedSubProcesses[] = [ + 'tas_uid' => $tasUid, + 'pm3_process_uid' => $pm3ProcessUid, + 'pm4_process_id' => (string) $pm4ProcessId, + 'element_id' => $elementId, + ]; + $modified = true; + } + + if ($modified) { + $process->bpmn = $definitions->saveXML(); + $process->saveOrFail(); + } + + return [ + 'applied_timers' => $appliedTimers, + 'applied_sub_processes' => $appliedSubProcesses, + 'warnings' => $warnings, + ]; + } + + private function hasBoundaryTimer(DOMXPath $xpath, string $elementId): bool + { + return $xpath->query("//bpmn:boundaryEvent[@attachedToRef='{$elementId}']/bpmn:timerEventDefinition")->length > 0; + } + + /** + * @param array $timer + */ + private function buildIsoDuration(array $timer): string + { + $days = (int) ($timer['day'] ?? 0); + $hours = (int) ($timer['hour'] ?? 0); + $minutes = (int) ($timer['minute'] ?? 0); + + if ($days === 0 && $hours === 0 && $minutes === 0) { + foreach (['configuration_data', 'config'] as $key) { + $raw = $timer[$key] ?? null; + if (!is_string($raw) || trim($raw) === '') { + continue; + } + + $parsed = json_decode($raw, true); + if (!is_array($parsed)) { + continue; + } + + $days = (int) ($parsed['days'] ?? $parsed['day'] ?? $days); + $hours = (int) ($parsed['hours'] ?? $parsed['hour'] ?? $hours); + $minutes = (int) ($parsed['minutes'] ?? $parsed['minute'] ?? $minutes); + break; + } + } + + if ($days === 0 && $hours === 0 && $minutes === 0) { + $option = strtoupper((string) ($timer['option'] ?? '')); + if ($option === 'HOURLY') { + $hours = 1; + } elseif ($option === 'DAILY') { + $days = 1; + } else { + return 'PT1H'; + } + } + + $duration = 'P'; + if ($days > 0) { + $duration .= $days . 'D'; + } + + $duration .= 'T'; + if ($hours > 0) { + $duration .= $hours . 'H'; + } + if ($minutes > 0) { + $duration .= $minutes . 'M'; + } + + if (str_ends_with($duration, 'T')) { + $duration .= '0S'; + } + + return $duration; + } + + private function appendBoundaryTimer( + DOMDocument $definitions, + string $attachedToRef, + string $boundaryId, + string $endId, + string $flowId, + string $duration + ): void { + $processNode = $definitions->getElementsByTagNameNS(MigrationBpmn::BPMN_NS, 'process')->item(0); + if (!($processNode instanceof DOMElement)) { + return; + } + + $boundary = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:boundaryEvent'); + $boundary->setAttribute('id', $boundaryId); + $boundary->setAttribute('attachedToRef', $attachedToRef); + $boundary->setAttribute('cancelActivity', 'true'); + + $timerDefinition = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:timerEventDefinition'); + $timeDuration = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:timeDuration', $duration); + $timerDefinition->appendChild($timeDuration); + $boundary->appendChild($timerDefinition); + + $outgoing = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:outgoing', $flowId); + $boundary->appendChild($outgoing); + + $endEvent = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:endEvent'); + $endEvent->setAttribute('id', $endId); + $endEvent->setAttribute('name', 'Timer Expired'); + + $incoming = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:incoming', $flowId); + $endEvent->appendChild($incoming); + + $flow = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:sequenceFlow'); + $flow->setAttribute('id', $flowId); + $flow->setAttribute('sourceRef', $boundaryId); + $flow->setAttribute('targetRef', $endId); + + $processNode->appendChild($boundary); + $processNode->appendChild($endEvent); + $processNode->appendChild($flow); + } + + private function replaceWithCallActivity( + DOMDocument $definitions, + DOMElement $taskNode, + string $pm4ProcessId, + string $startEventId + ): void { + $callActivity = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'bpmn:callActivity'); + $callActivity->setAttribute('id', $taskNode->getAttribute('id')); + $callActivity->setAttribute('name', $taskNode->getAttribute('name') ?: 'Sub Process'); + + $calledElement = 'ProcessId-' . $pm4ProcessId; + $callActivity->setAttribute('calledElement', $calledElement); + $callActivity->setAttributeNS( + WorkflowServiceProvider::PROCESS_MAKER_NS, + 'pm:config', + json_encode([ + 'calledElement' => $calledElement, + 'processId' => (int) $pm4ProcessId, + 'startEvent' => $startEventId, + 'name' => $callActivity->getAttribute('name'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}' + ); + + foreach ($taskNode->childNodes as $child) { + if ($child instanceof DOMElement && in_array($child->localName, ['incoming', 'outgoing'], true)) { + $callActivity->appendChild($child->cloneNode(true)); + } + } + + $taskNode->parentNode?->replaceChild($callActivity, $taskNode); + } +} diff --git a/ProcessMaker/Mcp/Migration/CssScopeConverter.php b/ProcessMaker/Mcp/Migration/CssScopeConverter.php new file mode 100644 index 0000000000..7d8aaf8fe0 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/CssScopeConverter.php @@ -0,0 +1,366 @@ +> + */ + private const PM3_TYPE_SCOPES = [ + 'text' => ['input', 'textarea', '.form-control'], + 'email' => ['input', '.form-control'], + 'phone' => ['input', '.form-control'], + 'number' => ['input', '.form-control'], + 'textarea' => ['textarea', '.form-control'], + 'dropdown' => ['select', '.form-control', '.multiselect'], + 'select' => ['select', '.form-control', '.multiselect'], + 'suggest' => ['input', '.form-control', '.multiselect'], + 'checkbox' => ['input[type="checkbox"]', '.custom-control-input'], + 'radio' => ['input[type="radio"]', '.custom-control-input', 'select', '.form-control'], + 'checkgroup' => ['select', '.form-control', '.multiselect'], + 'checkboxlist' => ['select', '.form-control', '.multiselect'], + 'date' => ['input', '.form-control', '.vdp-datepicker input'], + 'datetime' => ['input', '.form-control', '.vdp-datepicker input'], + 'currency' => ['input', '.form-control'], + 'percentage' => ['input', '.form-control'], + 'file' => ['input[type="file"]', '.form-control'], + 'hidden' => ['input[type="hidden"]'], + 'grid' => ['.table', '.record-list', '.vuetable'], + 'button' => ['button', '.btn'], + 'title' => ['h1', 'h2', 'h3', '.label'], + 'subtitle' => ['h2', 'h3', 'h4', '.label'], + ]; + + /** + * @var array> + */ + private const PM3_TAG_TYPE_MAP = [ + 'input' => ['text', 'email', 'phone', 'number', 'date', 'datetime', 'currency', 'percentage', 'file', 'hidden'], + 'select' => ['dropdown', 'select', 'suggest', 'radio', 'checkgroup', 'checkboxlist'], + 'textarea' => ['textarea'], + 'button' => ['button'], + ]; + + /** + * @param array> $fieldRegistry + * @return array{css: string|null, warnings: array} + */ + public function convert(string $rawCss, array $fieldRegistry): array + { + $rawCss = trim($rawCss); + if ($rawCss === '') { + return ['css' => null, 'warnings' => []]; + } + + $warnings = []; + $scopedChunks = []; + + foreach ($this->parseRules($rawCss) as $rule) { + $converted = $this->convertRule($rule['selectors'], $rule['declarations'], $fieldRegistry, $warnings); + if ($converted !== null) { + $scopedChunks[] = $converted; + } + } + + if ($scopedChunks === []) { + return ['css' => null, 'warnings' => $warnings]; + } + + return [ + 'css' => implode("\n\n", array_unique($scopedChunks)), + 'warnings' => $warnings, + ]; + } + + /** + * @param array $pm3Field + * @return array + */ + public function registryEntryFromPm3Field(array $pm3Field, string $selector, string $component): array + { + $type = strtolower((string) ($pm3Field['type'] ?? 'text')); + + return [ + 'selector' => $selector, + 'component' => $component, + 'pm3_type' => $type, + 'pm3_id' => $pm3Field['id'] ?? null, + 'pm3_name' => $pm3Field['name'] ?? null, + 'pm3_variable' => $pm3Field['variable'] ?? $pm3Field['var_name'] ?? null, + ]; + } + + /** + * @param array $pm3Field + */ + public function buildInlineFieldCss(array $pm3Field, string $selector): ?string + { + $declarations = []; + + $textTransform = $pm3Field['textTransform'] ?? null; + if (is_string($textTransform) && $textTransform !== '' && $textTransform !== 'none') { + $declarations[] = 'text-transform: ' . $textTransform . ';'; + } + + $colSpan = (int) ($pm3Field['colSpan'] ?? 0); + if ($colSpan > 0 && $colSpan < 12) { + $width = round(($colSpan / 12) * 100, 2); + $declarations[] = 'width: ' . $width . '%;'; + } + + if ($declarations === []) { + return null; + } + + return $this->scopeDeclarations($selector, $pm3Field, implode("\n ", $declarations)); + } + + /** + * @return array + */ + private function parseRules(string $css): array + { + $css = preg_replace('/\/\*.*?\*\//s', '', $css) ?? $css; + $rules = []; + + if (!preg_match_all('/([^{]+)\{([^}]*)\}/s', $css, $matches, PREG_SET_ORDER)) { + return []; + } + + foreach ($matches as $match) { + $selectors = trim($match[1]); + $declarations = trim($match[2]); + + if ($selectors === '' || $declarations === '') { + continue; + } + + $rules[] = [ + 'selectors' => $selectors, + 'declarations' => $declarations, + ]; + } + + return $rules; + } + + /** + * @param array> $fieldRegistry + * @param array $warnings + */ + private function convertRule( + string $selectors, + string $declarations, + array $fieldRegistry, + array &$warnings + ): ?string { + $declarations = $this->sanitizeDeclarations($declarations); + if ($declarations === '') { + return null; + } + + if ($this->isPm3SkinNoise($selectors)) { + $warnings[] = 'Skipped PM3 skin selector: ' . $selectors; + + return null; + } + + $selectorParts = array_map('trim', explode(',', $selectors)); + $scopedSelectors = []; + + foreach ($selectorParts as $selector) { + if ($selector === '') { + continue; + } + + $matchedFields = $this->matchFields($selector, $fieldRegistry); + + if ($matchedFields !== []) { + foreach ($matchedFields as $field) { + $scopedSelectors[] = $this->buildScopedSelectorList($field); + } + continue; + } + + $tagMatches = $this->matchFieldsByTag($selector, $fieldRegistry); + if ($tagMatches !== []) { + foreach ($tagMatches as $field) { + $scopedSelectors[] = $this->buildScopedSelectorList($field); + } + continue; + } + + if ($this->isGlobalSelector($selector)) { + $warnings[] = 'Global selector kept at screen scope: ' . $selector; + $scopedSelectors[] = $this->adaptGlobalSelector($selector); + + continue; + } + + $warnings[] = 'Unmapped PM3 selector skipped: ' . $selector; + } + + if ($scopedSelectors === []) { + return null; + } + + return implode(",\n", array_unique($scopedSelectors)) . " {\n {$declarations}\n}"; + } + + /** + * @param array> $fieldRegistry + * @return array> + */ + private function matchFields(string $selector, array $fieldRegistry): array + { + $needle = $this->normalizeSelectorToken($selector); + if ($needle === '') { + return []; + } + + $matches = []; + foreach ($fieldRegistry as $field) { + foreach ([ + $field['selector'] ?? null, + $field['pm3_id'] ?? null, + $field['pm3_name'] ?? null, + $field['pm3_variable'] ?? null, + ] as $candidate) { + if (!is_string($candidate) || $candidate === '') { + continue; + } + + if ($this->normalizeSelectorToken($candidate) === $needle) { + $matches[] = $field; + break; + } + } + } + + return $matches; + } + + /** + * @param array> $fieldRegistry + * @return array> + */ + private function matchFieldsByTag(string $selector, array $fieldRegistry): array + { + $tag = strtolower(trim($selector)); + $types = self::PM3_TAG_TYPE_MAP[$tag] ?? null; + + if ($types === null) { + if (in_array($tag, ['.x-form-text', '.x-form-field', '.x-form-item'], true)) { + $types = self::PM3_TAG_TYPE_MAP['input']; + } elseif ($tag === '.x-form-checkbox') { + $types = ['checkbox']; + } + } + + if ($types === null) { + return []; + } + + return array_values(array_filter( + $fieldRegistry, + fn ($field) => in_array($field['pm3_type'] ?? '', $types, true) + )); + } + + private function isGlobalSelector(string $selector): bool + { + return in_array(strtolower(trim($selector)), ['*', 'body', 'form', 'html'], true); + } + + private function adaptGlobalSelector(string $selector): string + { + return '.screen-container ' . trim($selector); + } + + private function isPm3SkinNoise(string $selectors): bool + { + return (bool) preg_match( + '/\b(app_menuRight|x-form-invalid|x-panel|x-toolbar|ext-|pmdynaform|processmaker)\b/i', + $selectors + ); + } + + private function normalizeSelectorToken(string $selector): string + { + $selector = trim($selector); + $selector = preg_replace('/^#|\.$|\[name=["\']|["\']\]$/', '', $selector) ?? $selector; + $selector = preg_replace('/^#|^\./', '', $selector) ?? $selector; + + return strtolower($this->slug($selector)); + } + + /** + * @param array $field + */ + private function buildScopedSelectorList(array $field): string + { + $selector = (string) ($field['selector'] ?? 'field'); + $targets = $this->componentTargets($field); + $parts = ["[selector='{$selector}']"]; + + foreach ($targets as $target) { + $parts[] = "[selector='{$selector}'] {$target}"; + } + + return implode(",\n", $parts); + } + + /** + * @param array $field + * @return array + */ + private function componentTargets(array $field): array + { + $type = strtolower((string) ($field['pm3_type'] ?? 'text')); + + return self::PM3_TYPE_SCOPES[$type] + ?? self::PM3_TYPE_SCOPES['text']; + } + + private function scopeDeclarations(string $selector, array $field, string $declarations): string + { + return $this->buildScopedSelectorList(array_merge($field, ['selector' => $selector])) + . " {\n {$declarations}\n}"; + } + + private function sanitizeDeclarations(string $declarations): string + { + $lines = preg_split('/;\s*/', $declarations) ?: []; + $clean = []; + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + + if (preg_match('/^(font-family|font-size|filter|opacity|behavior)\s*:/i', $line)) { + continue; + } + + $clean[] = $line; + } + + if ($clean === []) { + return ''; + } + + return implode(";\n ", $clean) . ';'; + } + + private function slug(string $value): string + { + $value = preg_replace('/[^a-zA-Z0-9_]+/', '_', $value) ?? 'field'; + $value = trim($value, '_'); + + return $value !== '' ? strtolower($value) : 'field'; + } +} diff --git a/ProcessMaker/Mcp/Migration/DynaformConverter.php b/ProcessMaker/Mcp/Migration/DynaformConverter.php new file mode 100644 index 0000000000..85139aad4e --- /dev/null +++ b/ProcessMaker/Mcp/Migration/DynaformConverter.php @@ -0,0 +1,521 @@ +> + */ + private array $fieldRegistry = []; + + /** + * @param array $dynaform + * @return array{ + * config: array, + * grid_definitions: array>, + * custom_css: string|null, + * field_registry: array>, + * warnings: array + * } + */ + public function toScreenConfig(array $dynaform): array + { + $this->fieldRegistry = []; + $content = $this->parseContent($dynaform); + $title = $dynaform['DYN_TITLE'] ?? $content['name'] ?? 'Migrated Screen'; + $warnings = []; + $items = []; + $gridDefinitions = []; + $cssConverter = new CssScopeConverter(); + $cssChunks = []; + + foreach ($this->extractFields($content) as $field) { + $type = strtolower((string) ($field['type'] ?? '')); + + if ($type === 'grid') { + $gridDefinitions[] = $field; + $this->registerGridField($field, $cssConverter); + foreach ($this->extractGridColumns($field) as $columnField) { + $this->appendInlineFieldCss($cssConverter, $columnField, $cssChunks); + } + + continue; + } + + $converted = $this->convertField($field, $warnings); + if ($converted === null) { + continue; + } + + if (isset($converted[0]) && is_array($converted[0])) { + foreach ($converted as $item) { + $items[] = $item; + } + } else { + $items[] = $converted; + } + + $this->appendInlineFieldCss($cssConverter, $field, $cssChunks); + } + + if ($items === [] && $gridDefinitions === []) { + $items[] = $this->placeholderInput('migrated_field', 'Migrated Field'); + $warnings[] = 'No supported controls found; placeholder field was added.'; + } + + if ($this->hasInputFields($items)) { + $items[] = $this->buildSubmitButton(); + } + + $rawCss = $this->extractCustomCss($content); + if (is_string($rawCss) && $rawCss !== '') { + $convertedCss = $cssConverter->convert($rawCss, $this->fieldRegistry); + $warnings = array_merge($warnings, $convertedCss['warnings']); + if (is_string($convertedCss['css']) && $convertedCss['css'] !== '') { + array_unshift($cssChunks, $convertedCss['css']); + } + } + + return [ + 'config' => [ + [ + 'name' => $title, + 'computed' => [], + 'items' => $items, + ], + ], + 'grid_definitions' => $gridDefinitions, + 'custom_css' => $cssChunks === [] ? null : implode("\n\n", $cssChunks), + 'field_registry' => $this->fieldRegistry, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $gridField + * @return array{items: array>, options_list: array>, warnings: array} + */ + public function convertGridColumns(array $gridField): array + { + $warnings = []; + $items = []; + $optionsList = []; + + foreach ($this->extractGridColumns($gridField) as $columnField) { + $converted = $this->convertField($columnField, $warnings); + if ($converted === null || (isset($converted[0]) && is_array($converted[0]))) { + continue; + } + + $items[] = $converted; + $optionsList[] = [ + 'value' => $converted['config']['name'], + 'content' => (string) ($converted['config']['label'] ?? $converted['config']['name']), + ]; + } + + if ($items === []) { + $items[] = $this->placeholderInput('grid_column', 'Grid Column'); + $optionsList[] = ['value' => 'grid_column', 'content' => 'Grid Column']; + $warnings[] = 'Grid had no supported columns; placeholder column was added.'; + } + + return [ + 'items' => $items, + 'options_list' => $optionsList, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $gridField + * @return array + */ + public function buildRecordListItem(array $gridField, string $nestedScreenId, ?array $columns = null): array + { + $variable = $gridField['variable'] ?? $gridField['name'] ?? 'grid'; + $label = $gridField['label'] ?? $gridField['name'] ?? 'Record List'; + $slug = $this->slug((string) $variable); + $columns ??= $this->convertGridColumns($gridField); + $optionsList = $columns['options_list']; + + return ScreenControlCatalog::cloneControl('FormRecordList', [ + 'form' => $nestedScreenId, + 'icon' => 'fas fa-th-list', + 'name' => $slug, + 'label' => (string) $label, + 'editable' => true, + 'customCssSelector' => $slug, + 'fields' => [ + 'jsonData' => json_encode($optionsList), + 'editIndex' => null, + 'dataSource' => 'provideData', + 'optionsList' => $optionsList, + 'removeIndex' => null, + 'showJsonEditor' => false, + 'showOptionCard' => false, + 'showRemoveWarning' => false, + ], + ], (string) $label); + } + + /** + * @param array> $items + */ + private function hasInputFields(array $items): bool + { + foreach ($items as $item) { + $component = (string) ($item['component'] ?? ''); + if (in_array($component, ['FormInput', 'FormTextArea', 'FormSelect', 'FormSelectList', 'FormCheckbox', 'FormDatePicker', 'FormRecordList'], true)) { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function buildSubmitButton(): array + { + return ScreenControlCatalog::cloneControl('FormButton', [ + 'event' => 'submit', + 'label' => 'Submit', + 'variant' => 'primary', + 'defaultSubmit' => true, + ], 'Submit Button'); + } + + /** + * @param array $dynaform + * @return array + */ + private function parseContent(array $dynaform): array + { + $raw = $dynaform['DYN_CONTENT'] ?? '{}'; + if (is_array($raw)) { + return $raw; + } + + $decoded = json_decode((string) $raw, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array $content + * @return array> + */ + private function extractFields(array $content): array + { + $fields = []; + $rootItems = $content['items'] ?? []; + + if (!is_array($rootItems)) { + return $fields; + } + + foreach ($rootItems as $item) { + if (!is_array($item)) { + continue; + } + + if (($item['type'] ?? null) === 'form' && is_array($item['items'] ?? null)) { + foreach ($item['items'] as $row) { + if (!is_array($row)) { + continue; + } + + foreach ($row as $field) { + if (is_array($field)) { + $fields[] = $field; + } + } + } + + continue; + } + + $fields[] = $item; + } + + return $fields; + } + + /** + * @param array $gridField + * @return array> + */ + private function extractGridColumns(array $gridField): array + { + $columns = []; + + foreach ($gridField['columns'] ?? [] as $column) { + if (!is_array($column)) { + continue; + } + + if (isset($column['type'])) { + $columns[] = $column; + continue; + } + + foreach ($column['fields'] ?? [] as $nestedField) { + if (is_array($nestedField)) { + $columns[] = $nestedField; + } + } + } + + return $columns; + } + + /** + * @param array $content + */ + private function extractCustomCss(array $content): ?string + { + foreach (['css', 'customCss', 'custom_css', 'stylesheet'] as $key) { + $value = $content[$key] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return null; + } + + /** + * @param array $field + * @param array $warnings + * @return array|array>|null + */ + private function convertField(array $field, array &$warnings): array|null + { + $type = strtolower((string) ($field['type'] ?? 'text')); + $label = $field['label'] ?? $field['name'] ?? 'Field'; + $variable = $field['variable'] ?? $field['var_name'] ?? $this->slug((string) $label); + + if (in_array($type, ['title', 'subtitle', 'button'], true)) { + return null; + } + + $selector = $this->slug((string) $variable); + + if ($type === 'hidden') { + $this->registerConvertedField($field, $selector, 'FormInput'); + + return $this->buildInput($variable, $label, 'FormInput', 'string', 'text', null, true, null, $selector); + } + + if (in_array($type, ['radio', 'radiogroup'], true)) { + $this->registerConvertedField($field, $selector, 'FormSelect'); + + return $this->buildSelect($field, $variable, $label, $warnings, false); + } + + if (in_array($type, ['checkgroup', 'checkboxlist'], true)) { + $this->registerConvertedField($field, $selector, 'FormSelect'); + + return $this->buildSelect($field, $variable, $label, $warnings, true); + } + + if ($type === 'file') { + $warnings[] = "File upload '{$variable}' requires PM4 file control review."; + $this->registerConvertedField($field, $selector, 'FormInput'); + + return $this->buildInput($variable, $label, 'FormInput', 'string', 'text', null, false, null, $selector); + } + + $mapping = match ($type) { + 'textarea' => ['component' => 'FormTextArea', 'dataFormat' => 'string', 'inputType' => 'text'], + 'dropdown', 'suggest', 'select' => ['component' => 'FormSelect', 'dataFormat' => 'string', 'inputType' => 'text'], + 'checkbox' => ['component' => 'FormCheckbox', 'dataFormat' => 'boolean', 'inputType' => 'checkbox'], + 'date', 'datetime' => ['component' => 'FormDatePicker', 'dataFormat' => 'datetime', 'inputType' => 'text'], + 'currency' => ['component' => 'FormInput', 'dataFormat' => 'currency', 'inputType' => 'text'], + 'percentage' => ['component' => 'FormInput', 'dataFormat' => 'percentage', 'inputType' => 'text'], + 'text', 'email', 'phone', 'number' => ['component' => 'FormInput', 'dataFormat' => 'string', 'inputType' => $type === 'number' ? 'number' : 'text'], + default => null, + }; + + if ($mapping === null) { + $warnings[] = "Unsupported dynaform control type '{$type}' for field '{$variable}'."; + $mapping = ['component' => 'FormInput', 'dataFormat' => 'string', 'inputType' => 'text']; + } + + $this->registerConvertedField($field, $selector, $mapping['component']); + + return $this->buildInput( + $variable, + $label, + $mapping['component'], + $mapping['dataFormat'], + $mapping['inputType'], + !empty($field['required']) ? 'required' : null, + false, + $field['placeholder'] ?? null, + $selector + ); + } + + /** + * @param array $field + * @param array $warnings + * @return array + */ + private function buildSelect( + array $field, + string $variable, + mixed $label, + array &$warnings, + bool $multiple + ): array { + $options = []; + foreach ($field['options'] ?? [] as $option) { + if (!is_array($option)) { + continue; + } + + $value = $option['value'] ?? $option['id'] ?? null; + if ($value === null) { + continue; + } + + $options[] = [ + 'value' => (string) $value, + 'content' => (string) ($option['label'] ?? $option['content'] ?? $value), + ]; + } + + if ($options === []) { + $warnings[] = "Select field '{$variable}' has no options; placeholder option added."; + $options[] = ['value' => 'option_1', 'content' => 'Option 1']; + } + + $config = [ + 'name' => $this->slug((string) $variable), + 'label' => is_string($label) ? $label : (string) $variable, + 'helper' => null, + 'dataFormat' => 'string', + 'validation' => !empty($field['required']) ? 'required' : null, + 'placeholder' => null, + 'options' => $options, + 'customCssSelector' => $this->slug((string) $variable), + ]; + + if ($multiple) { + $config['multiple'] = true; + } + + return ScreenControlCatalog::cloneControl('FormSelect', $config, (string) $label); + } + + /** + * @return array + */ + private function buildInput( + string $variable, + mixed $label, + string $component, + string $dataFormat, + string $inputType, + ?string $validation = null, + bool $hidden = false, + ?string $placeholder = null, + ?string $customCssSelector = null + ): array { + $selector = $customCssSelector ?? $this->slug((string) $variable); + $labelText = is_string($label) ? $label : (string) $variable; + + $config = [ + 'name' => $selector, + 'label' => $labelText, + 'helper' => null, + 'dataFormat' => $dataFormat, + 'validation' => $validation, + 'placeholder' => $placeholder, + 'hidden' => $hidden, + 'customCssSelector' => $selector, + ]; + + if ($component === 'FormInput') { + $config['type'] = $inputType; + } + + if ($component === 'FormTextArea') { + $config['rows'] = 2; + } + + return ScreenControlCatalog::cloneControl($component, $config, $labelText); + } + + /** + * @param array $field + */ + private function registerConvertedField(array $field, string $selector, string $component): void + { + $this->fieldRegistry[] = (new CssScopeConverter())->registryEntryFromPm3Field( + $field, + $selector, + $component + ); + } + + /** + * @param array $gridField + */ + private function registerGridField(array $gridField, CssScopeConverter $cssConverter): void + { + $selector = $this->slug((string) ($gridField['variable'] ?? $gridField['name'] ?? 'grid')); + $this->fieldRegistry[] = $cssConverter->registryEntryFromPm3Field( + $gridField, + $selector, + 'FormRecordList' + ); + + foreach ($this->extractGridColumns($gridField) as $columnField) { + $columnSelector = $this->slug((string) ($columnField['variable'] ?? $columnField['name'] ?? 'column')); + $this->fieldRegistry[] = $cssConverter->registryEntryFromPm3Field( + $columnField, + $columnSelector, + 'FormInput' + ); + } + } + + /** + * @param array $cssChunks + * @param array $field + */ + private function appendInlineFieldCss(CssScopeConverter $cssConverter, array $field, array &$cssChunks): void + { + $selector = $this->slug((string) ($field['variable'] ?? $field['name'] ?? 'field')); + $inlineCss = $cssConverter->buildInlineFieldCss($field, $selector); + + if (is_string($inlineCss) && $inlineCss !== '') { + $cssChunks[] = $inlineCss; + } + } + + /** + * @return array + */ + private function placeholderInput(string $name, string $label): array + { + return $this->buildInput($name, $label, 'FormInput', 'string', 'text'); + } + + private function slug(string $value): string + { + $value = preg_replace('/[^a-zA-Z0-9_]+/', '_', $value) ?? 'field'; + $value = trim($value, '_'); + + return $value !== '' ? strtolower($value) : 'field'; + } +} diff --git a/ProcessMaker/Mcp/Migration/ListAllTools.php b/ProcessMaker/Mcp/Migration/ListAllTools.php new file mode 100644 index 0000000000..f819fb8308 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/ListAllTools.php @@ -0,0 +1,32 @@ +tools(); + $perPage = max($tools->count(), $context->perPage($request->get('per_page'))); + + $paginator = new CursorPaginator( + items: $tools, + perPage: $perPage, + cursor: $request->cursor(), + ); + + return JsonRpcResponse::result($request->id, $paginator->paginate('tools')); + } +} diff --git a/ProcessMaker/Mcp/Migration/MigrationBpmn.php b/ProcessMaker/Mcp/Migration/MigrationBpmn.php new file mode 100644 index 0000000000..d5d9ccf644 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/MigrationBpmn.php @@ -0,0 +1,421 @@ +registerNamespace('bpmn', self::BPMN_NS); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + + return $xpath; + } + + /** + * PM3 exports numeric element UIDs as BPMN ids; PM4 XSD requires NCName (letter/_ first). + * + * @return array{xml: string, id_map: array, warnings: array} + */ + public static function sanitizeXmlIds(string $xml): array + { + $document = new DOMDocument(); + if (@$document->loadXML($xml) === false) { + return [ + 'xml' => $xml, + 'id_map' => [], + 'warnings' => ['Unable to parse BPMN XML for ID sanitization.'], + ]; + } + + $xpath = self::createXPath($document); + $idMap = []; + $counter = 1; + + foreach ($xpath->query('//*[@id]') as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + $oldId = $node->getAttribute('id'); + if ($oldId === '' || self::isValidBpmnId($oldId)) { + continue; + } + + $idMap[$oldId] = self::makeValidBpmnId($oldId, $counter++); + } + + if ($idMap === []) { + self::normalizeFlowNodeChildOrder($xpath); + + return [ + 'xml' => self::ensureDiagramInterchange($document->saveXML() ?: $xml), + 'id_map' => [], + 'warnings' => [], + ]; + } + + self::remapAttributeValues($xpath, 'id', $idMap); + self::remapAttributeValues($xpath, 'sourceRef', $idMap); + self::remapAttributeValues($xpath, 'targetRef', $idMap); + self::remapAttributeValues($xpath, 'attachedToRef', $idMap); + self::remapAttributeValues($xpath, 'bpmnElement', $idMap); + self::remapFlowReferenceElements($xpath, $idMap); + self::normalizeFlowNodeChildOrder($xpath); + + $xml = $document->saveXML() ?: $xml; + + return [ + 'xml' => self::ensureDiagramInterchange($xml), + 'id_map' => $idMap, + 'warnings' => ['Remapped ' . count($idMap) . ' PM3 BPMN element IDs to PM4-compatible values.'], + ]; + } + + /** + * @return array + */ + public static function validate(string $bpmn): array + { + $schemaErrors = []; + $document = new BpmnDocument(); + + try { + $document->loadXML($bpmn); + } catch (\ErrorException $e) { + return [$e->getMessage()]; + } + + try { + $document->validateBPMNSchema(public_path('definitions/ProcessMaker.xsd')); + } catch (\Exception $e) { + $schemaErrors = $document->getValidationErrors(); + $schemaErrors[] = $e->getMessage(); + } + + $diagrams = $document->getElementsByTagNameNS( + 'http://www.omg.org/spec/BPMN/20100524/DI', + 'BPMNDiagram' + ); + if ($diagrams->length > 1) { + $schemaErrors[] = 'Multiple diagrams are not supported'; + } + + $rulesValidation = new BPMNValidation(); + if (!$rulesValidation->passes('document', $document)) { + $schemaErrors[] = [ + 'title' => 'BPMN Validation failed', + 'text' => 'Some bpmn elements do not comply with the validation', + 'errors' => $rulesValidation->errors('document', $document)->getMessages(), + ]; + } + + return $schemaErrors; + } + + /** + * PM3 MCP export includes BPMNDiagram; only synthesize layout when it is missing. + */ + private static function ensureDiagramInterchange(string $xml): string + { + if (stripos($xml, 'BPMNDiagram') !== false) { + return $xml; + } + + return BpmnDiagramInterchange::ensure($xml); + } + + /** + * @param array $idMap + * @return array + */ + public static function remapBundleElementIds(array $bundle, array $idMap): array + { + if ($idMap === []) { + return $bundle; + } + + $remap = static fn (?string $value): ?string => ($value !== null && $value !== '' && isset($idMap[$value])) + ? $idMap[$value] + : $value; + + foreach ($bundle['task_element_map'] ?? [] as $index => $entry) { + if (!is_array($entry)) { + continue; + } + + $bundle['task_element_map'][$index]['element_id'] = $remap((string) ($entry['element_id'] ?? '')); + if (isset($entry['tas_uid'], $idMap[$entry['tas_uid']])) { + $bundle['task_element_map'][$index]['tas_uid'] = $idMap[$entry['tas_uid']]; + } + } + + foreach ($bundle['tasks'] ?? [] as $index => $task) { + if (!is_array($task)) { + continue; + } + + $tasUid = (string) ($task['tas_uid'] ?? ''); + if ($tasUid !== '' && isset($idMap[$tasUid])) { + $bundle['tasks'][$index]['tas_uid'] = $idMap[$tasUid]; + } + } + + foreach ($bundle['task_assignments'] ?? [] as $index => $assignment) { + if (!is_array($assignment)) { + continue; + } + + $tasUid = (string) ($assignment['tas_uid'] ?? ''); + if ($tasUid !== '' && isset($idMap[$tasUid])) { + $bundle['task_assignments'][$index]['tas_uid'] = $idMap[$tasUid]; + } + } + + foreach (['steps', 'document_steps', 'routes', 'gateways', 'web_entries', 'abe_configurations', 'schedulers', 'sub_processes'] as $section) { + foreach ($bundle[$section] ?? [] as $index => $entry) { + if (!is_array($entry)) { + continue; + } + + $bundle[$section][$index] = self::remapEntryUidFields($entry, $idMap); + } + } + + foreach ($bundle['script_tasks'] ?? [] as $index => $scriptTask) { + if (!is_array($scriptTask)) { + continue; + } + + $bundle['script_tasks'][$index] = self::remapEntryUidFields($scriptTask, $idMap, ['act_uid', 'tas_uid']); + } + + if (($bundle['task_element_map'] ?? []) === [] && ($bundle['tasks'] ?? []) !== []) { + foreach ($bundle['tasks'] as $task) { + if (!is_array($task)) { + continue; + } + + $tasUid = (string) ($task['tas_uid'] ?? ''); + if ($tasUid !== '') { + $bundle['task_element_map'][] = [ + 'tas_uid' => $tasUid, + 'element_id' => $tasUid, + ]; + } + } + } + + return $bundle; + } + + /** + * @param array $entry + * @param array $idMap + * @param array $fields + * @return array + */ + private static function remapEntryUidFields(array $entry, array $idMap, array $fields = ['tas_uid']): array + { + foreach ($fields as $field) { + $value = (string) ($entry[$field] ?? ''); + if ($value !== '' && isset($idMap[$value])) { + $entry[$field] = $idMap[$value]; + } + } + + return $entry; + } + + private static function isValidBpmnId(string $id): bool + { + return (bool) preg_match('/^[A-Za-z_][A-Za-z0-9_.-]*$/', $id); + } + + private static function makeValidBpmnId(string $oldId, int $counter): string + { + $suffix = substr(preg_replace('/[^A-Za-z0-9_]+/', '', $oldId) ?? '', -8); + $candidate = 'node_' . ($suffix !== '' ? $suffix : (string) $counter); + + return self::isValidBpmnId($candidate) ? $candidate : 'node_' . $counter; + } + + /** + * @param array $idMap + */ + private static function remapAttributeValues(DOMXPath $xpath, string $attribute, array $idMap): void + { + foreach ($xpath->query('//*[@' . $attribute . ']') as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + $value = $node->getAttribute($attribute); + if ($value !== '' && isset($idMap[$value])) { + $node->setAttribute($attribute, $idMap[$value]); + } + } + } + + /** + * @param array $idMap + */ + private static function remapFlowReferenceElements(DOMXPath $xpath, array $idMap): void + { + foreach (['incoming', 'outgoing'] as $tag) { + foreach ($xpath->query('//bpmn:' . $tag) as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + $value = trim($node->textContent); + if ($value !== '' && isset($idMap[$value])) { + while ($node->firstChild !== null) { + $node->removeChild($node->firstChild); + } + $node->appendChild($node->ownerDocument->createTextNode($idMap[$value])); + } + } + } + } + + private static function normalizeFlowNodeChildOrder(DOMXPath $xpath): void + { + $flowNodeQuery = '//bpmn:task | //bpmn:userTask | //bpmn:scriptTask' + . ' | //bpmn:exclusiveGateway | //bpmn:parallelGateway | //bpmn:inclusiveGateway' + . ' | //bpmn:complexGateway | //bpmn:eventBasedGateway'; + + foreach ($xpath->query($flowNodeQuery) as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + self::reorderFlowChildren($node, ['incoming', 'outgoing']); + } + } + + /** + * @param array $orderedTags + */ + private static function reorderFlowChildren(DOMElement $node, array $orderedTags): void + { + $bpmnNs = self::BPMN_NS; + $groups = array_fill_keys($orderedTags, []); + $toRemove = []; + + foreach ($node->childNodes as $child) { + if (!($child instanceof DOMElement)) { + continue; + } + + if ($child->namespaceURI === $bpmnNs && in_array($child->localName, $orderedTags, true)) { + $groups[$child->localName][] = $child; + $toRemove[] = $child; + } + } + + if ($toRemove === []) { + return; + } + + $anchor = $toRemove[0]; + foreach ($toRemove as $child) { + $node->removeChild($child); + } + + $insertAt = $anchor; + foreach ($orderedTags as $tag) { + foreach ($groups[$tag] as $flowNode) { + if ($insertAt !== null && $insertAt->parentNode === $node) { + $node->insertBefore($flowNode, $insertAt); + } else { + $node->appendChild($flowNode); + } + } + } + } + + /** + * @param array $payload + * @return array + */ + public static function buildTaskElementMap(array $payload, DOMXPath $xpath): array + { + $map = []; + + foreach (self::taskElementMapEntries($payload) as $entry) { + if (!is_array($entry)) { + continue; + } + + $tasUid = $entry['tas_uid'] ?? null; + $elementId = $entry['element_id'] ?? $entry['element_uid'] ?? null; + + if (is_string($tasUid) && $tasUid !== '' && is_string($elementId) && $elementId !== '') { + $map[$tasUid] = $elementId; + } + } + + $titlesByName = []; + foreach ($xpath->query('//bpmn:task[@id] | //bpmn:userTask[@id] | //bpmn:scriptTask[@id]') as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + + $elementId = $node->getAttribute('id'); + if ($elementId === '') { + continue; + } + + $map[$elementId] ??= $elementId; + + $name = $node->getAttribute('name'); + if ($name === '') { + continue; + } + + $titlesByName[strtolower(trim($name))] ??= $elementId; + } + + foreach ($payload['tasks'] ?? [] as $task) { + if (!is_array($task)) { + continue; + } + + $tasUid = $task['tas_uid'] ?? null; + $tasTitle = $task['tas_title'] ?? null; + + if (!is_string($tasUid) || $tasUid === '' || isset($map[$tasUid]) || !is_string($tasTitle)) { + continue; + } + + $key = strtolower(trim($tasTitle)); + if (isset($titlesByName[$key])) { + $map[$tasUid] = $titlesByName[$key]; + } + } + + return $map; + } + + /** + * @param array $payload + * @return array + */ + private static function taskElementMapEntries(array $payload): array + { + return $payload['task_element_map'] ?? []; + } +} diff --git a/ProcessMaker/Mcp/Migration/MigrationGapReport.php b/ProcessMaker/Mcp/Migration/MigrationGapReport.php new file mode 100644 index 0000000000..8814ebba87 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/MigrationGapReport.php @@ -0,0 +1,85 @@ + $bundle + * @param array $result + * @return array{ready: bool, gaps: array, score: int} + */ + public function build(array $bundle, array $result): array + { + $gaps = []; + + if (($bundle['variables'] ?? []) !== [] && ($result['applied_variables'] ?? []) === []) { + $gaps[] = 'Process variables were not stored.'; + } + + foreach ($bundle['steps'] ?? [] as $step) { + if (!is_array($step) || ($step['step_type'] ?? null) !== 'DYNAFORM') { + continue; + } + + $dynUid = $step['dyn_uid'] ?? null; + $linked = false; + foreach ($result['linked_screens'] ?? [] as $link) { + if (is_array($link) && ($link['dyn_uid'] ?? null) === $dynUid) { + $linked = true; + break; + } + } + + if (!$linked) { + $gaps[] = 'Dynaform step ' . ($dynUid ?? 'unknown') . ' is not linked to a BPMN task.'; + } + } + + foreach ($bundle['steps'] ?? [] as $step) { + if (!is_array($step) || ($step['triggers'] ?? []) === []) { + continue; + } + + $tasUid = $step['tas_uid'] ?? 'unknown'; + $applied = count(array_filter( + $result['applied_step_triggers'] ?? [], + fn ($entry) => is_array($entry) && ($entry['tas_uid'] ?? null) === $tasUid + )); + + if ($applied < count($step['triggers'])) { + $gaps[] = 'Step triggers on task ' . $tasUid . ' are incomplete.'; + } + } + + if (($bundle['web_entries'] ?? []) !== [] && ($result['applied_web_entries'] ?? []) === []) { + $gaps[] = 'Web entries were not configured.'; + } + + if (($bundle['document_steps'] ?? []) !== [] + && !in_array('document_steps', $result['applied_documents'] ?? [], true)) { + $gaps[] = 'Input/output document steps were not stored.'; + } + + if (($result['validation']['valid'] ?? false) !== true) { + $gaps[] = 'Process BPMN validation failed.'; + } + + foreach ($result['warnings'] ?? [] as $warning) { + if (is_string($warning) && str_contains($warning, 'PM3 API')) { + $gaps[] = $warning; + } + } + + $total = max(1, count($bundle['tasks'] ?? []) + count($bundle['triggers'] ?? []) + count($bundle['variables'] ?? [])); + $score = (int) round((1 - count($gaps) / $total) * 100); + + return [ + 'ready' => $gaps === [], + 'gaps' => array_values(array_unique($gaps)), + 'score' => max(0, min(100, $score)), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/MigrationLog.php b/ProcessMaker/Mcp/Migration/MigrationLog.php new file mode 100644 index 0000000000..0439ed1e73 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/MigrationLog.php @@ -0,0 +1,347 @@ +exists($path)) { + return null; + } + + $decoded = json_decode(Storage::disk('local')->get($path), true); + if (!is_array($decoded)) { + return null; + } + + return new self($proUid, $decoded); + } + + /** + * @return array> + */ + public static function listAll(): array + { + if (!Storage::disk('local')->exists('migration-logs')) { + return []; + } + + $summaries = []; + foreach (Storage::disk('local')->files('migration-logs') as $path) { + if (!str_ends_with($path, '.json')) { + continue; + } + + $decoded = json_decode(Storage::disk('local')->get($path), true); + if (!is_array($decoded)) { + continue; + } + + $proUid = $decoded['pro_uid'] ?? basename($path, '.json'); + $log = new self((string) $proUid, $decoded); + $summaries[] = $log->toSummary(); + } + + usort($summaries, static fn (array $a, array $b): int => strcmp( + (string) ($b['updated_at'] ?? ''), + (string) ($a['updated_at'] ?? '') + )); + + return $summaries; + } + + public static function storagePath(string $proUid): string + { + $safeUid = preg_replace('/[^a-zA-Z0-9\-_]/', '_', $proUid) ?: 'unknown'; + + return 'migration-logs/' . $safeUid . '.json'; + } + + /** + * @param array $bundle + */ + public function start(array $bundle): void + { + $now = now()->toIso8601String(); + $source = is_array($bundle['source'] ?? null) ? $bundle['source'] : []; + + $this->data = [ + 'migration_id' => $this->proUid, + 'pro_uid' => $this->proUid, + 'process_title' => $source['title'] ?? 'Migrated Process', + 'status' => 'in_progress', + 'started_at' => $now, + 'updated_at' => $now, + 'completed_at' => null, + 'failed_at' => null, + 'failed_step' => null, + 'error' => null, + 'process_id' => null, + 'steps' => array_fill_keys(self::STEPS, ['status' => 'pending']), + 'artifacts' => [ + 'scripts' => [], + 'screens' => [], + 'collections' => [], + ], + 'step_results' => [], + 'completed_steps' => [], + 'pending_steps' => self::STEPS, + 'result' => null, + ]; + + $this->save(); + } + + public function delete(): void + { + Storage::disk('local')->delete(self::storagePath($this->proUid)); + $this->data = []; + } + + public function canResume(): bool + { + return in_array($this->data['status'] ?? '', ['in_progress', 'failed'], true) + && !empty($this->data['process_id']); + } + + public function getStatus(): string + { + return (string) ($this->data['status'] ?? 'unknown'); + } + + public function getProcessId(): ?int + { + $processId = $this->data['process_id'] ?? null; + + return is_numeric($processId) ? (int) $processId : null; + } + + public function setProcessId(int $processId): void + { + $this->data['process_id'] = $processId; + $this->touch(); + $this->save(); + } + + public function isStepDone(string $step): bool + { + return ($this->data['steps'][$step]['status'] ?? null) === 'done'; + } + + /** + * @return array> + */ + public function getArtifacts(string $type): array + { + $artifacts = $this->data['artifacts'][$type] ?? []; + + return is_array($artifacts) ? $artifacts : []; + } + + /** + * @param array $artifact + */ + public function addArtifact(string $type, array $artifact): void + { + if (!isset($this->data['artifacts'][$type]) || !is_array($this->data['artifacts'][$type])) { + $this->data['artifacts'][$type] = []; + } + + $this->data['artifacts'][$type][] = $artifact; + $this->touch(); + $this->save(); + } + + /** + * @param array|null $result + */ + public function completeStep(string $step, ?array $result = null): void + { + $this->data['steps'][$step] = [ + 'status' => 'done', + 'completed_at' => now()->toIso8601String(), + ]; + + if ($result !== null) { + $this->data['step_results'][$step] = $result; + } + + $this->data['completed_steps'] = array_values(array_filter( + self::STEPS, + fn (string $name): bool => ($this->data['steps'][$name]['status'] ?? null) === 'done' + )); + $this->data['pending_steps'] = array_values(array_filter( + self::STEPS, + fn (string $name): bool => ($this->data['steps'][$name]['status'] ?? null) !== 'done' + )); + + $this->touch(); + $this->save(); + } + + public function fail(string $step, string $message): void + { + $this->data['status'] = 'failed'; + $this->data['failed_at'] = now()->toIso8601String(); + $this->data['failed_step'] = $step; + $this->data['error'] = $message; + $this->data['steps'][$step] = array_merge( + is_array($this->data['steps'][$step] ?? null) ? $this->data['steps'][$step] : [], + [ + 'status' => 'failed', + 'failed_at' => now()->toIso8601String(), + 'error' => $message, + ] + ); + $this->touch(); + $this->save(); + } + + /** + * @param array $result + */ + public function complete(array $result): void + { + $this->data['status'] = 'completed'; + $this->data['completed_at'] = now()->toIso8601String(); + $this->data['failed_at'] = null; + $this->data['failed_step'] = null; + $this->data['error'] = null; + $this->data['result'] = $result; + $this->data['completed_steps'] = self::STEPS; + $this->data['pending_steps'] = []; + $this->touch(); + $this->save(); + } + + /** + * @return array|null + */ + public function getStepResult(string $step): ?array + { + $result = $this->data['step_results'][$step] ?? null; + + return is_array($result) ? $result : null; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->data; + } + + /** + * @return array + */ + public function toSummary(): array + { + return [ + 'migration_id' => $this->data['migration_id'] ?? $this->proUid, + 'pro_uid' => $this->proUid, + 'process_title' => $this->data['process_title'] ?? null, + 'status' => $this->data['status'] ?? 'unknown', + 'process_id' => $this->getProcessId(), + 'started_at' => $this->data['started_at'] ?? null, + 'updated_at' => $this->data['updated_at'] ?? null, + 'completed_at' => $this->data['completed_at'] ?? null, + 'failed_at' => $this->data['failed_at'] ?? null, + 'failed_step' => $this->data['failed_step'] ?? null, + 'error' => $this->data['error'] ?? null, + 'completed_steps' => $this->data['completed_steps'] ?? [], + 'pending_steps' => $this->data['pending_steps'] ?? self::STEPS, + 'artifacts_summary' => [ + 'scripts' => count($this->getArtifacts('scripts')), + 'screens' => count($this->getArtifacts('screens')), + 'collections' => count($this->getArtifacts('collections')), + ], + 'log_path' => self::storagePath($this->proUid), + ]; + } + + /** + * @return array + */ + public function getVerificationReport(): array + { + return [ + 'summary' => $this->toSummary(), + 'artifacts' => $this->data['artifacts'] ?? [], + 'step_results' => $this->data['step_results'] ?? [], + 'steps' => $this->data['steps'] ?? [], + ]; + } + + private function touch(): void + { + $this->data['updated_at'] = now()->toIso8601String(); + } + + private function save(): void + { + Storage::disk('local')->put( + self::storagePath($this->proUid), + json_encode($this->data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + } +} diff --git a/ProcessMaker/Mcp/Migration/ProcessVariableMigrator.php b/ProcessMaker/Mcp/Migration/ProcessVariableMigrator.php new file mode 100644 index 0000000000..244f8602e4 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/ProcessVariableMigrator.php @@ -0,0 +1,111 @@ + $variables + * @return array{applied: array, warnings: array} + */ + public function apply(Process $process, array $variables): array + { + $catalog = []; + $defaults = []; + $applied = []; + $warnings = []; + + foreach ($variables as $variable) { + if (!is_array($variable)) { + continue; + } + + $name = trim((string) ($variable['var_name'] ?? '')); + if ($name === '') { + continue; + } + + $type = $this->mapType($variable['var_field_type'] ?? null); + $default = $variable['var_default'] ?? null; + + $catalog[] = [ + 'name' => $name, + 'label' => (string) ($variable['var_label'] ?? $name), + 'type' => $type, + 'default' => $default, + 'pm3_uid' => $variable['var_uid'] ?? null, + ]; + + if ($default !== null && $default !== '') { + $defaults[$name] = $this->castDefault($default, $type); + } + + if (!empty($variable['var_sql'])) { + $warnings[] = 'Variable ' . $name . ' uses SQL and needs manual datasource setup.'; + } + + $applied[] = $name; + } + + if ($catalog === []) { + return ['applied' => [], 'warnings' => $warnings]; + } + + $properties = is_array($process->properties) ? $process->properties : []; + $properties['variables'] = $catalog; + + if ($defaults !== []) { + $properties['request_data_defaults'] = $defaults; + } + + McpSupport::ensureActingUser($process); + app(PlatformWriter::class)->updateProcess($process, ['properties' => $properties]); + $process->refresh(); + + return ['applied' => $applied, 'warnings' => $warnings]; + } + + private function mapType(mixed $fieldType): string + { + $type = strtoupper(trim((string) $fieldType)); + + return match ($type) { + 'INTEGER', 'INT' => 'integer', + 'FLOAT', 'DOUBLE', 'REAL' => 'float', + 'BOOLEAN', 'BOOL' => 'boolean', + 'GRID', 'ARRAY' => 'array', + default => 'string', + }; + } + + private function castDefault(mixed $default, string $type): mixed + { + if (!is_string($default)) { + return $default; + } + + return match ($type) { + 'integer' => (int) $default, + 'float' => (float) $default, + 'boolean' => filter_var($default, FILTER_VALIDATE_BOOLEAN), + 'array' => $this->decodeArrayDefault($default), + default => $default, + }; + } + + /** + * @return array|string + */ + private function decodeArrayDefault(string $default): array|string + { + $decoded = json_decode($default, true); + + return is_array($decoded) ? $decoded : $default; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ApplyAbeConfigurationTool.php b/ProcessMaker/Mcp/Migration/Tools/ApplyAbeConfigurationTool.php new file mode 100644 index 0000000000..b4764146a2 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ApplyAbeConfigurationTool.php @@ -0,0 +1,52 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'abe_configurations' => 'required|array', + 'screens' => 'nullable|array', + 'tasks' => 'nullable|array', + 'task_element_map' => 'nullable|array', + 'email_server_map' => 'nullable|array', + ]); + + return Response::structured($this->writer->applyAbeConfiguration( + $data['process_id'], + $data + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + 'abe_configurations' => $schema->array()->description('Normalized PM3 ABE configurations')->required(), + 'screens' => $schema->array()->description('Screen mappings from migrated dynaforms'), + 'tasks' => $schema->array()->description('PM3 normalized tasks'), + 'task_element_map' => $schema->array()->description('tas_uid to BPMN element_id map'), + 'email_server_map' => $schema->array()->description('Optional PM3 MESS_UID to PM4 email server ID map'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ApplyMigrationBundleTool.php b/ProcessMaker/Mcp/Migration/Tools/ApplyMigrationBundleTool.php new file mode 100644 index 0000000000..39951fd673 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ApplyMigrationBundleTool.php @@ -0,0 +1,47 @@ +validate([ + 'bundle' => 'required|array', + 'resume' => 'sometimes|boolean', + 'fresh' => 'sometimes|boolean', + ]); + + return Response::structured($this->writer->applyMigrationBundle( + $data['bundle'], + (bool) ($data['resume'] ?? false), + (bool) ($data['fresh'] ?? false), + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'bundle' => $schema->object()->description('Normalized PM3 bundle JSON')->required(), + 'resume' => $schema->boolean()->description('Continue from an interrupted migration log'), + 'fresh' => $schema->boolean()->description('Delete any existing log and start over'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ApplyTaskAssignmentsTool.php b/ProcessMaker/Mcp/Migration/Tools/ApplyTaskAssignmentsTool.php new file mode 100644 index 0000000000..0bc4cf8df7 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ApplyTaskAssignmentsTool.php @@ -0,0 +1,50 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'task_assignments' => 'required|array', + 'tasks' => 'nullable|array', + 'task_element_map' => 'nullable|array', + 'identity_map' => 'nullable|array', + ]); + + return Response::structured($this->writer->applyTaskAssignments( + $data['process_id'], + $data + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + 'task_assignments' => $schema->array()->description('Normalized PM3 task assignments')->required(), + 'tasks' => $schema->array()->description('PM3 normalized tasks'), + 'task_element_map' => $schema->array()->description('tas_uid to BPMN element_id map'), + 'identity_map' => $schema->object()->description('Optional; omit for default migration. Task assignment is configured manually in PM4 after import.'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/CreateCollectionFromReportTableTool.php b/ProcessMaker/Mcp/Migration/Tools/CreateCollectionFromReportTableTool.php new file mode 100644 index 0000000000..44463641f5 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/CreateCollectionFromReportTableTool.php @@ -0,0 +1,50 @@ +validate([ + 'report_table' => 'required|array', + 'fields' => 'nullable|array', + ]); + + $result = $this->writer->createCollectionFromReportTable( + $data['report_table'], + $data['fields'] ?? ($data['report_table']['fields'] ?? []) + ); + + return Response::structured([ + 'collection_id' => $result['collection']->id, + 'name' => $result['collection']->name, + 'warnings' => $result['warnings'], + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'report_table' => $schema->object()->description('Normalized PM3 report table')->required(), + 'fields' => $schema->array()->description('Optional report table field definitions'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/CreateProcessTool.php b/ProcessMaker/Mcp/Migration/Tools/CreateProcessTool.php new file mode 100644 index 0000000000..d5a7f17652 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/CreateProcessTool.php @@ -0,0 +1,53 @@ +validate([ + 'name' => 'required|string', + 'description' => 'nullable|string', + 'status' => 'nullable|string|in:ACTIVE,INACTIVE,ARCHIVED', + 'bpmn' => 'nullable|string', + 'process_category_id' => 'nullable|integer|exists:process_categories,id', + ]); + + $process = $this->writer->createProcess($data); + + return Response::structured([ + 'id' => $process->id, + 'name' => $process->name, + 'status' => $process->status, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Process name')->required(), + 'description' => $schema->string()->description('Process description'), + 'status' => $schema->string()->description('ACTIVE, INACTIVE, or ARCHIVED'), + 'bpmn' => $schema->string()->description('BPMN XML definition'), + 'process_category_id' => $schema->integer()->description('Process category ID'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/CreateScreenFromDynaformTool.php b/ProcessMaker/Mcp/Migration/Tools/CreateScreenFromDynaformTool.php new file mode 100644 index 0000000000..d9c38ef04b --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/CreateScreenFromDynaformTool.php @@ -0,0 +1,53 @@ +validate([ + 'dynaform' => 'required|array', + 'stylesheets' => 'nullable|array', + ]); + + $result = $this->writer->createScreenFromDynaform( + $data['dynaform'], + $data['stylesheets'] ?? [] + ); + + return Response::structured([ + 'id' => $result['screen']->id, + 'title' => $result['screen']->title, + 'type' => $result['screen']->type, + 'warnings' => $result['warnings'], + 'nested_screens' => $result['nested_screens'], + 'custom_css' => $result['screen']->custom_css, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'dynaform' => $schema->object()->description('PM3 dynaform row with DYN_CONTENT')->required(), + 'stylesheets' => $schema->array()->description('Optional PM3 stylesheet assets for custom_css'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/CreateScreenTool.php b/ProcessMaker/Mcp/Migration/Tools/CreateScreenTool.php new file mode 100644 index 0000000000..ceabe3e303 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/CreateScreenTool.php @@ -0,0 +1,50 @@ +validate([ + 'title' => 'required|string', + 'description' => 'nullable|string', + 'type' => 'nullable|string', + 'config_json' => 'required', + ]); + + $result = $this->writer->createScreenResult($data); + + return Response::structured($this->writer->screenCreationPayload($result)); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string()->description('Screen title')->required(), + 'description' => $schema->string()->description('Screen description'), + 'type' => $schema->string()->description('Screen type, e.g. FORM'), + 'config_json' => $schema->anyOf([ + $schema->string()->description('Screen config JSON string'), + $schema->object()->description('Screen config object'), + ])->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/CreateScriptTool.php b/ProcessMaker/Mcp/Migration/Tools/CreateScriptTool.php new file mode 100644 index 0000000000..4c737bdc4e --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/CreateScriptTool.php @@ -0,0 +1,53 @@ +validate([ + 'title' => 'required|string', + 'description' => 'nullable|string', + 'code' => 'required|string', + 'language' => 'nullable|string', + 'run_as_user_id' => 'nullable|integer|exists:users,id', + ]); + + $script = $this->writer->createScript($data); + + return Response::structured([ + 'id' => $script->id, + 'title' => $script->title, + 'language' => $script->language, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string()->description('Script title')->required(), + 'description' => $schema->string()->description('Script description'), + 'code' => $schema->string()->description('Script source code')->required(), + 'language' => $schema->string()->description('Script language, e.g. php'), + 'run_as_user_id' => $schema->integer()->description('User ID to run as'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/GetMigrationGapReportTool.php b/ProcessMaker/Mcp/Migration/Tools/GetMigrationGapReportTool.php new file mode 100644 index 0000000000..a4826bf7c6 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/GetMigrationGapReportTool.php @@ -0,0 +1,37 @@ +validate([ + 'bundle' => 'required|array', + 'result' => 'required|array', + ]); + + return Response::structured((new MigrationGapReport())->build($data['bundle'], $data['result'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'bundle' => $schema->object()->description('Normalized PM3 bundle')->required(), + 'result' => $schema->object()->description('Result returned by apply_migration_bundle')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/GetMigrationLogTool.php b/ProcessMaker/Mcp/Migration/Tools/GetMigrationLogTool.php new file mode 100644 index 0000000000..60a7172ed3 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/GetMigrationLogTool.php @@ -0,0 +1,39 @@ +validate([ + 'pro_uid' => 'required|string', + ]); + + return Response::structured($this->writer->getMigrationLog($data['pro_uid'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'pro_uid' => $schema->string()->description('PM3 process UID used as migration log key')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/LinkMigrationAssetsTool.php b/ProcessMaker/Mcp/Migration/Tools/LinkMigrationAssetsTool.php new file mode 100644 index 0000000000..9d5447ef70 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/LinkMigrationAssetsTool.php @@ -0,0 +1,54 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'screens' => 'nullable|array', + 'scripts' => 'nullable|array', + 'steps' => 'nullable|array', + 'tasks' => 'nullable|array', + 'script_tasks' => 'nullable|array', + 'task_element_map' => 'nullable|array', + ]); + + return Response::structured($this->writer->autoLinkMigrationAssets( + $data['process_id'], + $data + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + 'screens' => $schema->array()->description('Screen mappings from apply_migration_bundle'), + 'scripts' => $schema->array()->description('Script mappings from apply_migration_bundle'), + 'steps' => $schema->array()->description('PM3 normalized steps'), + 'tasks' => $schema->array()->description('PM3 normalized tasks'), + 'script_tasks' => $schema->array()->description('PM3 script task relations'), + 'task_element_map' => $schema->array()->description('tas_uid to BPMN element_id map'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/LinkScreenToTaskTool.php b/ProcessMaker/Mcp/Migration/Tools/LinkScreenToTaskTool.php new file mode 100644 index 0000000000..51f791e219 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/LinkScreenToTaskTool.php @@ -0,0 +1,53 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'element_id' => 'required|string', + 'screen_id' => 'required|string|exists:screens,id', + ]); + + $process = $this->writer->linkScreenToTask( + $data['process_id'], + $data['element_id'], + $data['screen_id'] + ); + + return Response::structured([ + 'process_id' => $process->id, + 'element_id' => $data['element_id'], + 'screen_id' => $data['screen_id'], + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('Process ID')->required(), + 'element_id' => $schema->string()->description('BPMN element ID')->required(), + 'screen_id' => $schema->string()->description('Screen ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/LinkScriptToTaskTool.php b/ProcessMaker/Mcp/Migration/Tools/LinkScriptToTaskTool.php new file mode 100644 index 0000000000..e1b00b8d31 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/LinkScriptToTaskTool.php @@ -0,0 +1,53 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'element_id' => 'required|string', + 'script_id' => 'required|string|exists:scripts,id', + ]); + + $process = $this->writer->linkScriptToTask( + $data['process_id'], + $data['element_id'], + $data['script_id'] + ); + + return Response::structured([ + 'process_id' => $process->id, + 'element_id' => $data['element_id'], + 'script_id' => $data['script_id'], + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('Process ID')->required(), + 'element_id' => $schema->string()->description('BPMN element ID')->required(), + 'script_id' => $schema->string()->description('Script ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ListMigrationLogsTool.php b/ProcessMaker/Mcp/Migration/Tools/ListMigrationLogsTool.php new file mode 100644 index 0000000000..616c52e662 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ListMigrationLogsTool.php @@ -0,0 +1,35 @@ + $this->writer->listMigrationLogs(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ResumeMigrationBundleTool.php b/ProcessMaker/Mcp/Migration/Tools/ResumeMigrationBundleTool.php new file mode 100644 index 0000000000..50b8b2688f --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ResumeMigrationBundleTool.php @@ -0,0 +1,39 @@ +validate([ + 'bundle' => 'required|array', + ]); + + return Response::structured($this->writer->resumeMigrationBundle($data['bundle'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'bundle' => $schema->object()->description('Same normalized PM3 bundle used for the original import')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/UpdateProcessBpmnTool.php b/ProcessMaker/Mcp/Migration/Tools/UpdateProcessBpmnTool.php new file mode 100644 index 0000000000..2ff0e2f588 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/UpdateProcessBpmnTool.php @@ -0,0 +1,46 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'bpmn' => 'required|string', + ]); + + $process = $this->writer->updateProcessBpmn($data['process_id'], $data['bpmn']); + + return Response::structured([ + 'process_id' => $process->id, + 'name' => $process->name, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('Process ID')->required(), + 'bpmn' => $schema->string()->description('BPMN XML')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/Tools/ValidateProcessTool.php b/ProcessMaker/Mcp/Migration/Tools/ValidateProcessTool.php new file mode 100644 index 0000000000..ec25bf323f --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Tools/ValidateProcessTool.php @@ -0,0 +1,39 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + ]); + + return Response::structured($this->writer->validateProcess($data['process_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('Process ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Migration/TriggerTranslator.php b/ProcessMaker/Mcp/Migration/TriggerTranslator.php new file mode 100644 index 0000000000..1eb5d18913 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/TriggerTranslator.php @@ -0,0 +1,78 @@ +convertPm3Assignments($code); + $code = $this->convertPm3VariableReads($code); + $code = $this->replacePm3ApiCalls($code); + + return $code; + } + + public function pm3VariableToMustache(mixed $variable): string + { + if (!is_string($variable) || trim($variable) === '') { + return ''; + } + + $variable = trim($variable); + if (str_starts_with($variable, '@@')) { + $variable = substr($variable, 2); + } elseif (str_starts_with($variable, '@')) { + $variable = substr($variable, 1); + } + + if ($variable === '' + || $variable === 'SYS_NEXT_USER_TO_BE_ASSIGNED' + || $variable === 'SYS_GROUP_TO_BE_ASSIGNED') { + return ''; + } + + return '{{ ' . $variable . ' }}'; + } + + public function replacePm3Variables(string $value): string + { + return preg_replace('/@@(\w+)/', '{{ $1 }}', $value) ?? $value; + } + + private function convertPm3Assignments(string $code): string + { + return preg_replace('/@@(\w+)\s*=/', "\$data['$1'] =", $code) ?? $code; + } + + private function convertPm3VariableReads(string $code): string + { + return preg_replace('/@@(\w+)/', "\$data['$1']", $code) ?? $code; + } + + private function replacePm3ApiCalls(string $code): string + { + $replacements = [ + 'PMFAssignUserToCase(' => '$api->assignUserToCase(', + 'PMFDerivateCase(' => '$api->derivateCase(', + 'PMFRedirectToStep(' => '$api->redirectToStep(', + 'PMFSendMessage(' => '$api->sendMessage(', + 'PMFExecuteTrigger(' => '$api->executeTrigger(', + 'PMFNewCase(' => '$api->newCase(', + 'PMFInfo(' => '$api->info(', + 'PMFDeleteCase(' => '$api->deleteCase(', + 'PMFGetCaseInfo(' => '$api->getCaseInfo(', + 'PMFUpdateCase(' => '$api->updateCase(', + 'PMFPauseCase(' => '$api->pauseCase(', + 'PMFUnpauseCase(' => '$api->unpauseCase(', + 'PMFCancelCase(' => '$api->cancelCase(', + 'executeQuery(' => '$api->executeQuery(', + 'wsSend(' => '$api->wsSend(', + 'G::LoadClass(' => '// G::LoadClass(', + ]; + + return str_replace(array_keys($replacements), array_values($replacements), $code); + } +} diff --git a/ProcessMaker/Mcp/Migration/Writer.php b/ProcessMaker/Mcp/Migration/Writer.php new file mode 100644 index 0000000000..332bef6b3d --- /dev/null +++ b/ProcessMaker/Mcp/Migration/Writer.php @@ -0,0 +1,1665 @@ +defaultProcessCategoryId(); + } + + $process = $this->platform()->createProcess($data); + + return $this->ensureProcessIsStartable($process); + } + + /** + * @param array $data + */ + public function updateProcess(string $processId, array $data): Process + { + $process = Process::findOrFail($processId); + + $payload = array_filter([ + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? null, + 'process_category_id' => $data['process_category_id'] ?? null, + ], fn ($value) => $value !== null); + + if ($payload === []) { + return $process; + } + + return $this->platform()->updateProcess($process, $payload); + } + + public function createScreen(array $data): Screen + { + return $this->createScreenResult($data)->screen; + } + + public function createScreenResult(array $data): ScreenCreationResult + { + $config = $this->parseConfigJson($data['config_json'] ?? $data['config'] ?? []); + $config = app(ScreenConfigNormalizer::class)->normalize($config); + + $payload = [ + 'title' => $data['title'], + 'description' => $data['description'] ?? $data['title'], + 'type' => $data['type'] ?? 'FORM', + 'config_json' => $config, + 'computed' => $data['computed'] ?? [], + 'watchers' => $data['watchers'] ?? [], + 'custom_css' => $data['custom_css'] ?? '', + 'screen_category_id' => $data['screen_category_id'] ?? $this->defaultScreenCategoryId(), + ]; + + return $this->platform()->createScreenResult($payload); + } + + /** + * Assign default category and start permissions so MCP processes appear in Launchpad. + */ + public function ensureProcessIsStartable(Process $process, ?int $userId = null): Process + { + $userId ??= Auth::id() ?? User::firstOrFail()->id; + + if ($process->categories()->count() === 0) { + $process->process_category_id = (string) $this->defaultProcessCategoryId(); + $process->saveOrFail(); + } + + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $changed = false; + + foreach ($xpath->query('//bpmn:startEvent[@id]') as $node) { + if (!($node instanceof \DOMElement)) { + continue; + } + + $assignment = $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'assignment'); + if ($assignment !== '') { + continue; + } + + $this->setPmAttributeOnNode($node, 'assignment', 'user'); + $this->setPmAttributeOnNode($node, 'assignedUsers', (string) $userId); + $this->setPmAttributeOnNode($node, 'assignedGroups', ''); + $changed = true; + } + + if ($changed) { + $this->platform()->persistProcessBpmn($process, $definitions->saveXML()); + $process->refresh(); + } + + return $process; + } + + /** + * @return array + */ + public function screenCreationPayload(ScreenCreationResult $result): array + { + $screen = $result->screen->fresh() ?? $result->screen; + $validation = app(ScreenBuilder::class)->validateScreenConfig($screen->config ?? []); + $firstItem = $screen->config[0]['items'][0] ?? null; + + return [ + 'id' => $screen->id, + 'title' => $screen->title, + 'type' => $screen->type, + 'import_method' => $result->importMethod, + 'warnings' => $result->warnings, + 'config_valid' => $validation['valid'], + 'config_issues' => $validation['issues'], + 'editor_ready' => is_array($firstItem) + && is_string($firstItem['uuid'] ?? null) + && ($firstItem['uuid'] ?? '') !== '' + && is_array($firstItem['inspector'] ?? null) + && ($firstItem['inspector'] ?? []) !== [], + 'mcp_version' => McpSupport::MCP_VERSION, + ]; + } + + public function createScript(array $data): Script + { + $user = Auth::user() ?? User::firstOrFail(); + + $payload = [ + 'title' => $data['title'], + 'description' => $data['description'] ?? $data['title'], + 'code' => $data['code'], + 'language' => $data['language'] ?? 'php', + 'run_as_user_id' => $data['run_as_user_id'] ?? $user->id, + 'script_category_id' => $data['script_category_id'] ?? $this->defaultScriptCategoryId(), + ]; + + return $this->platform()->createScript($payload); + } + + public function updateProcessBpmn(string $processId, string $bpmn): Process + { + $process = Process::findOrFail($processId); + + return $this->platform()->updateProcessBpmn($process, $bpmn); + } + + public function linkScreenToTask(string $processId, string $elementId, string $screenId): Process + { + Screen::findOrFail($screenId); + + return $this->setBpmnElementAttribute($processId, $elementId, 'screenRef', $screenId); + } + + public function linkScriptToTask(string $processId, string $elementId, string $scriptId): Process + { + Script::findOrFail($scriptId); + + return $this->setBpmnElementAttribute($processId, $elementId, 'scriptRef', $scriptId); + } + + /** + * @param array $payload + * @return array + */ + public function autoLinkMigrationAssets(string $processId, array $payload): array + { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + + $screensByPm3 = $this->indexAssetsByPm3($payload['screens'] ?? []); + $scriptsByPm3 = $this->indexAssetsByPm3($payload['scripts'] ?? []); + + $taskElementMap = MigrationBpmn::buildTaskElementMap($payload, $xpath); + $linkedScreens = []; + $linkedScripts = []; + $warnings = []; + $modified = false; + + foreach ($payload['steps'] ?? [] as $step) { + if (!is_array($step) || ($step['step_type'] ?? null) !== 'DYNAFORM') { + continue; + } + + $dynUid = $step['dyn_uid'] ?? null; + $tasUid = $step['tas_uid'] ?? null; + $screenId = is_string($dynUid) ? ($screensByPm3[$dynUid] ?? null) : null; + + if ($screenId === null || !is_string($tasUid)) { + continue; + } + + $elementId = $taskElementMap[$tasUid] ?? null; + if ($elementId === null || $this->findBpmnTaskNode($xpath, $elementId) === null) { + $warnings[] = "No BPMN task found for PM3 task {$tasUid}."; + continue; + } + + $this->setPmAttribute($xpath, $elementId, 'screenRef', $screenId); + $linkedScreens[] = [ + 'tas_uid' => $tasUid, + 'dyn_uid' => $dynUid, + 'element_id' => $elementId, + 'screen_id' => $screenId, + ]; + $modified = true; + } + + foreach ($payload['script_tasks'] ?? [] as $scriptTask) { + if (!is_array($scriptTask)) { + continue; + } + + $actUid = $scriptTask['act_uid'] ?? null; + $triUid = $scriptTask['tri_uid'] ?? null; + $scriptId = is_string($triUid) ? ($scriptsByPm3[$triUid] ?? null) : null; + + if ($scriptId === null || !is_string($actUid)) { + continue; + } + + $elementId = $taskElementMap[$actUid] ?? $actUid; + if ($xpath->query("//bpmn:scriptTask[@id='{$elementId}']")->item(0) === null) { + $warnings[] = "No BPMN scriptTask found for activity {$actUid}."; + continue; + } + + $this->setPmAttribute($xpath, $elementId, 'scriptRef', $scriptId); + $linkedScripts[] = [ + 'act_uid' => $actUid, + 'tri_uid' => $triUid, + 'element_id' => $elementId, + 'script_id' => $scriptId, + ]; + $modified = true; + } + + if ($modified) { + $this->saveProcessBpmn($process, $definitions); + } + + return [ + 'process_id' => $process->id, + 'linked_screens' => $linkedScreens, + 'linked_scripts' => $linkedScripts, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $dynaform + * @param array $stylesheets + * @return array{screen: Screen, warnings: array, nested_screens: array>} + */ + public function createScreenFromDynaform(array $dynaform, array $stylesheets = []): array + { + $converter = new DynaformConverter(); + $converted = $converter->toScreenConfig($dynaform); + $title = $dynaform['DYN_TITLE'] ?? 'Migrated Screen'; + $warnings = $converted['warnings']; + $config = $converted['config']; + $mainItems = $config[0]['items'] ?? []; + $nestedScreens = []; + + foreach ($converted['grid_definitions'] as $gridField) { + $gridLabel = $gridField['label'] ?? $gridField['variable'] ?? 'Grid'; + $columns = $converter->convertGridColumns($gridField); + $warnings = array_merge($warnings, $columns['warnings']); + + $nestedResult = $this->createScreenResult([ + 'title' => $title . ' - ' . $gridLabel, + 'description' => 'Grid columns migrated from PM3 dynaform', + 'config_json' => [[ + 'name' => 'data', + 'items' => $columns['items'], + ]], + ]); + $warnings = array_merge($warnings, $nestedResult->warnings); + $nestedScreen = $nestedResult->screen; + + $nestedScreens[] = [ + 'pm4_id' => $nestedScreen->id, + 'title' => $nestedScreen->title, + 'grid_variable' => $gridField['variable'] ?? $gridField['name'] ?? null, + ]; + + $mainItems[] = $converter->buildRecordListItem($gridField, (string) $nestedScreen->id, $columns); + } + + $config[0]['items'] = $mainItems; + + $screenResult = $this->createScreenResult([ + 'title' => $title, + 'description' => $dynaform['DYN_DESCRIPTION'] ?? $title, + 'config_json' => $config, + ]); + $warnings = array_merge($warnings, $screenResult->warnings); + $screen = $screenResult->screen; + + $customCss = $this->mergeCustomCss( + $converted['custom_css'], + $stylesheets, + $converted['field_registry'] ?? [] + ); + + if ($customCss !== null && $customCss !== '') { + $screen->custom_css = $customCss; + $screen->saveOrFail(); + } + + return [ + 'screen' => $screen, + 'warnings' => $warnings, + 'nested_screens' => $nestedScreens, + ]; + } + + /** + * @return array + */ + public function applyMigrationBundle(array $bundle, bool $resume = false, bool $fresh = false): array + { + $proUid = $this->resolveBundleProUid($bundle); + $existingLog = MigrationLog::load($proUid); + + if ($fresh && $existingLog !== null) { + $existingLog->delete(); + $existingLog = null; + } + + if ($resume) { + if ($existingLog === null || !$existingLog->canResume()) { + throw ValidationException::withMessages([ + 'resume' => "No resumable migration log found for pro_uid {$proUid}.", + ]); + } + + return $this->runMigrationBundle($bundle, $existingLog); + } + + if ($existingLog !== null) { + if ($existingLog->getStatus() === 'completed') { + throw ValidationException::withMessages([ + 'bundle' => "Migration for {$proUid} is already completed. Use fresh=true to restart.", + ]); + } + + if ($existingLog->canResume()) { + throw ValidationException::withMessages([ + 'bundle' => "Migration for {$proUid} was interrupted. Use resume=true to continue.", + ]); + } + } + + $log = MigrationLog::forProUid($proUid); + $log->start($bundle); + + return $this->runMigrationBundle($bundle, $log); + } + + /** + * @return array + */ + public function resumeMigrationBundle(array $bundle): array + { + return $this->applyMigrationBundle($bundle, resume: true); + } + + /** + * @return array + */ + public function getMigrationLog(string $proUid): array + { + $log = MigrationLog::load($proUid); + if ($log === null) { + throw ValidationException::withMessages([ + 'pro_uid' => "No migration log found for {$proUid}.", + ]); + } + + return $log->getVerificationReport(); + } + + /** + * @return array> + */ + public function listMigrationLogs(): array + { + return MigrationLog::listAll(); + } + + /** + * @return array + */ + private function runMigrationBundle(array $bundle, MigrationLog $log): array + { + $source = $bundle['source'] ?? []; + $title = $this->resolveMigrationProcessName((string) ($source['title'] ?? 'Migrated Process')); + $translator = new TriggerTranslator(); + $migrationWarnings = []; + + if (!$log->isStepDone(MigrationLog::STEP_CREATE_PROCESS)) { + try { + $processPayload = [ + 'name' => $title, + 'description' => 'Migrated from PM3: ' . ($source['pro_uid'] ?? 'unknown'), + ]; + + if (!empty($bundle['bpmn']['xml']) && is_string($bundle['bpmn']['xml'])) { + $sanitized = MigrationBpmn::sanitizeXmlIds($bundle['bpmn']['xml']); + $bundle = MigrationBpmn::remapBundleElementIds($bundle, $sanitized['id_map']); + $processPayload['bpmn'] = $sanitized['xml']; + $migrationWarnings = array_merge($migrationWarnings, $sanitized['warnings']); + } + + $process = $this->createProcess($processPayload); + $log->setProcessId((int) $process->id); + $log->completeStep(MigrationLog::STEP_CREATE_PROCESS); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_CREATE_PROCESS, $exception->getMessage()); + throw $exception; + } + } + + $process = Process::findOrFail($log->getProcessId()); + $scripts = $log->getArtifacts('scripts'); + $screens = $log->getArtifacts('screens'); + $collections = $log->getArtifacts('collections'); + $screenWarnings = $log->getStepResult(MigrationLog::STEP_CREATE_SCREENS)['warnings'] ?? []; + + if (!$log->isStepDone(MigrationLog::STEP_CREATE_SCRIPTS)) { + try { + $existingPm3Uids = collect($scripts) + ->pluck('pm3_uid') + ->filter() + ->all(); + + foreach ($bundle['triggers'] ?? [] as $trigger) { + if (!is_array($trigger)) { + continue; + } + + $pm3Uid = $trigger['TRI_UID'] ?? $trigger['tri_uid'] ?? null; + if (is_string($pm3Uid) && in_array($pm3Uid, $existingPm3Uids, true)) { + continue; + } + + $code = $trigger['TRI_WEBBOT'] ?? $trigger['tri_webbot'] ?? ''; + if ($code === '') { + continue; + } + + $scriptTitle = $this->resolveUniqueAssetTitle( + (string) ($trigger['TRI_TITLE'] ?? $trigger['tri_title'] ?? 'Migrated Trigger'), + Script::class, + ); + $scriptDescription = trim((string) ($trigger['TRI_DESCRIPTION'] ?? $trigger['tri_description'] ?? '')); + $script = $this->createScript([ + 'title' => $scriptTitle, + 'description' => $scriptDescription !== '' ? $scriptDescription : $scriptTitle, + 'code' => $translator->translate($code), + 'language' => 'php', + ]); + + $artifact = [ + 'pm3_uid' => $pm3Uid, + 'pm4_id' => $script->id, + 'title' => $script->title, + ]; + $scripts[] = $artifact; + $log->addArtifact('scripts', $artifact); + } + + $log->completeStep(MigrationLog::STEP_CREATE_SCRIPTS, ['count' => count($scripts)]); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_CREATE_SCRIPTS, $exception->getMessage()); + throw $exception; + } + } + + if (!$log->isStepDone(MigrationLog::STEP_CREATE_SCREENS)) { + try { + $existingPm3Uids = collect($screens) + ->pluck('pm3_uid') + ->filter() + ->all(); + $stylesheets = is_array($bundle['stylesheets'] ?? null) ? $bundle['stylesheets'] : []; + + foreach ($bundle['dynaforms'] ?? [] as $dynaform) { + if (!is_array($dynaform)) { + continue; + } + + $pm3Uid = $dynaform['DYN_UID'] ?? $dynaform['dyn_uid'] ?? null; + if (is_string($pm3Uid) && in_array($pm3Uid, $existingPm3Uids, true)) { + continue; + } + + $created = $this->createScreenFromDynaform($dynaform, $stylesheets); + $screen = $created['screen']; + + $artifact = [ + 'pm3_uid' => $pm3Uid, + 'pm4_id' => $screen->id, + 'title' => $screen->title, + ]; + $screens[] = $artifact; + $log->addArtifact('screens', $artifact); + + foreach ($created['nested_screens'] as $nestedScreen) { + $nestedArtifact = [ + 'pm3_uid' => null, + 'pm4_id' => $nestedScreen['pm4_id'], + 'title' => $nestedScreen['title'], + 'grid_variable' => $nestedScreen['grid_variable'] ?? null, + ]; + $screens[] = $nestedArtifact; + $log->addArtifact('screens', $nestedArtifact); + } + + foreach ($created['warnings'] as $warning) { + $screenWarnings[] = $screen->title . ': ' . $warning; + } + } + + $log->completeStep(MigrationLog::STEP_CREATE_SCREENS, [ + 'count' => count($screens), + 'warnings' => $screenWarnings, + ]); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_CREATE_SCREENS, $exception->getMessage()); + throw $exception; + } + } + + $linkPayload = $this->buildLinkPayload($bundle, $screens, $scripts); + + $linkResult = $log->getStepResult(MigrationLog::STEP_LINK_ASSETS) + ?? ['linked_screens' => [], 'linked_scripts' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_LINK_ASSETS)) { + try { + $linkResult = $this->autoLinkMigrationAssets((string) $process->id, $linkPayload); + $log->completeStep(MigrationLog::STEP_LINK_ASSETS, $linkResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_LINK_ASSETS, $exception->getMessage()); + throw $exception; + } + } + + $identityMap = $this->normalizeIdentityMap($bundle['identity_map'] ?? null); + $assignmentResult = $log->getStepResult(MigrationLog::STEP_APPLY_ASSIGNMENTS) + ?? ['applied' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_APPLY_ASSIGNMENTS)) { + try { + if (($bundle['task_assignments'] ?? []) !== []) { + $assignmentResult = $this->applyTaskAssignments((string) $process->id, array_merge($linkPayload, [ + 'task_assignments' => $bundle['task_assignments'], + 'identity_map' => $identityMap, + ])); + } + $log->completeStep(MigrationLog::STEP_APPLY_ASSIGNMENTS, $assignmentResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_APPLY_ASSIGNMENTS, $exception->getMessage()); + throw $exception; + } + } + + $variableResult = $log->getStepResult(MigrationLog::STEP_APPLY_VARIABLES) + ?? ['applied' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_APPLY_VARIABLES)) { + try { + $process->refresh(); + $variableResult = (new ProcessVariableMigrator())->apply($process, $bundle['variables'] ?? []); + $log->completeStep(MigrationLog::STEP_APPLY_VARIABLES, $variableResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_APPLY_VARIABLES, $exception->getMessage()); + throw $exception; + } + } + + $bpmnEnhancerResult = $log->getStepResult(MigrationLog::STEP_BPMN_ENHANCE) + ?? ['applied_timers' => [], 'applied_sub_processes' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_BPMN_ENHANCE)) { + try { + $process->refresh(); + $bpmnEnhancerResult = (new BpmnEnhancer())->apply($process, array_merge($linkPayload, [ + 'timer_events' => $bundle['timer_events'] ?? [], + 'case_schedulers' => $bundle['case_schedulers'] ?? [], + 'sub_processes' => $bundle['sub_processes'] ?? [], + 'subprocess_map' => $bundle['subprocess_map'] ?? [], + 'subprocess_start_event_map' => $bundle['subprocess_start_event_map'] ?? [], + ])); + $log->completeStep(MigrationLog::STEP_BPMN_ENHANCE, $bpmnEnhancerResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_BPMN_ENHANCE, $exception->getMessage()); + throw $exception; + } + } + + $propertiesResult = $log->getStepResult(MigrationLog::STEP_STORE_METADATA) + ?? ['applied_metadata' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_STORE_METADATA)) { + try { + $propertiesResult = $this->storeMigrationProperties((string) $process->id, $bundle); + $log->completeStep(MigrationLog::STEP_STORE_METADATA, $propertiesResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_STORE_METADATA, $exception->getMessage()); + throw $exception; + } + } + + $webResult = $log->getStepResult(MigrationLog::STEP_WEB_ENTRY) + ?? ['applied' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_WEB_ENTRY)) { + try { + $webResult = $this->applyWebEntry((string) $process->id, [ + 'web_entries' => $bundle['web_entries'] ?? [], + 'screens' => $screens, + ]); + $log->completeStep(MigrationLog::STEP_WEB_ENTRY, $webResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_WEB_ENTRY, $exception->getMessage()); + throw $exception; + } + } + + $stepTriggersResult = $log->getStepResult(MigrationLog::STEP_STEP_TRIGGERS) + ?? ['applied' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_STEP_TRIGGERS)) { + try { + $stepTriggersResult = $this->applyStepTriggers((string) $process->id, array_merge($linkPayload, [ + 'scripts' => $scripts, + ])); + $log->completeStep(MigrationLog::STEP_STEP_TRIGGERS, $stepTriggersResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_STEP_TRIGGERS, $exception->getMessage()); + throw $exception; + } + } + + $collectionWarnings = $log->getStepResult(MigrationLog::STEP_COLLECTIONS)['warnings'] ?? []; + if (!$log->isStepDone(MigrationLog::STEP_COLLECTIONS)) { + try { + $existingPm3Uids = collect($collections) + ->pluck('pm3_uid') + ->filter() + ->all(); + + foreach ($bundle['report_tables'] ?? [] as $reportTable) { + if (!is_array($reportTable)) { + continue; + } + + $pm3Uid = $reportTable['rep_tab_uid'] ?? $reportTable['REP_TAB_UID'] ?? null; + if (is_string($pm3Uid) && in_array($pm3Uid, $existingPm3Uids, true)) { + continue; + } + + try { + $created = $this->createCollectionFromReportTable( + $reportTable, + $reportTable['fields'] ?? [] + ); + $collection = $created['collection']; + + $artifact = [ + 'pm3_uid' => $pm3Uid, + 'pm4_id' => $collection->id, + 'name' => $collection->name, + ]; + $collections[] = $artifact; + $log->addArtifact('collections', $artifact); + + foreach ($created['warnings'] as $warning) { + $collectionWarnings[] = $collection->name . ': ' . $warning; + } + } catch (ValidationException $exception) { + $collectionWarnings[] = ($reportTable['rep_tab_title'] ?? 'Report table') + . ': ' . implode(' ', $exception->validator->errors()->all()); + } + } + + $log->completeStep(MigrationLog::STEP_COLLECTIONS, [ + 'count' => count($collections), + 'warnings' => $collectionWarnings, + ]); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_COLLECTIONS, $exception->getMessage()); + throw $exception; + } + } + + $abeResult = $log->getStepResult(MigrationLog::STEP_ABE) + ?? ['applied' => [], 'warnings' => []]; + if (!$log->isStepDone(MigrationLog::STEP_ABE)) { + try { + if (($bundle['abe_configurations'] ?? []) !== []) { + $abeResult = $this->applyAbeConfiguration((string) $process->id, array_merge($linkPayload, [ + 'abe_configurations' => $bundle['abe_configurations'], + 'screens' => $screens, + 'email_server_map' => $bundle['email_server_map'] ?? [], + ])); + } + $log->completeStep(MigrationLog::STEP_ABE, $abeResult); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_ABE, $exception->getMessage()); + throw $exception; + } + } + + $result = [ + 'process_id' => $process->id, + 'process_name' => $process->name, + 'scripts' => $scripts, + 'screens' => $screens, + 'collections' => $collections, + 'linked_screens' => $linkResult['linked_screens'] ?? [], + 'linked_scripts' => $linkResult['linked_scripts'] ?? [], + 'applied_assignments' => $assignmentResult['applied'] ?? [], + 'applied_variables' => $variableResult['applied'] ?? [], + 'applied_timers' => $bpmnEnhancerResult['applied_timers'] ?? [], + 'applied_sub_processes' => $bpmnEnhancerResult['applied_sub_processes'] ?? [], + 'applied_web_entries' => $webResult['applied'] ?? [], + 'applied_step_triggers' => $stepTriggersResult['applied'] ?? [], + 'applied_documents' => $propertiesResult['applied_metadata'] ?? [], + 'applied_abe' => $abeResult['applied'] ?? [], + 'warnings' => array_values(array_filter(array_merge( + $migrationWarnings, + $screenWarnings, + $linkResult['warnings'] ?? [], + $assignmentResult['warnings'] ?? [], + $variableResult['warnings'] ?? [], + $bpmnEnhancerResult['warnings'] ?? [], + $propertiesResult['warnings'] ?? [], + $webResult['warnings'] ?? [], + $stepTriggersResult['warnings'] ?? [], + $collectionWarnings, + $abeResult['warnings'] ?? [], + is_array($bundle['bpmn']['warnings'] ?? null) ? $bundle['bpmn']['warnings'] : [] + ))), + ]; + + if (!$log->isStepDone(MigrationLog::STEP_FINALIZE)) { + try { + $result['validation'] = $this->validateProcess((string) $process->id); + $result['gap_report'] = (new MigrationGapReport())->build($bundle, $result); + $log->completeStep(MigrationLog::STEP_FINALIZE, [ + 'validation' => $result['validation'], + 'gap_report' => $result['gap_report'], + ]); + } catch (\Throwable $exception) { + $log->fail(MigrationLog::STEP_FINALIZE, $exception->getMessage()); + throw $exception; + } + } else { + $finalizeResult = $log->getStepResult(MigrationLog::STEP_FINALIZE) ?? []; + $result['validation'] = $finalizeResult['validation'] ?? $this->validateProcess((string) $process->id); + $result['gap_report'] = $finalizeResult['gap_report'] + ?? (new MigrationGapReport())->build($bundle, $result); + } + + $log->complete($result); + $result['migration_log'] = $log->toSummary(); + + return $result; + } + + private function resolveBundleProUid(array $bundle): string + { + $source = is_array($bundle['source'] ?? null) ? $bundle['source'] : []; + $proUid = $source['pro_uid'] ?? null; + + if (!is_string($proUid) || $proUid === '') { + throw ValidationException::withMessages([ + 'bundle.source.pro_uid' => 'Required for migration logging and resume.', + ]); + } + + return $proUid; + } + + /** + * @return array{ + * applied_metadata: array, + * warnings: array + * } + */ + public function storeMigrationProperties(string $processId, array $bundle): array + { + $propertyMap = [ + 'document_steps' => 'migration_document_steps', + 'document_definitions' => 'migration_document_definitions', + 'timer_events' => 'migration_timer_events', + 'case_schedulers' => 'migration_case_schedulers', + 'sub_processes' => 'migration_sub_processes', + 'web_entry_events' => 'migration_web_entry_events', + ]; + + $process = Process::findOrFail($processId); + $properties = is_array($process->properties) ? $process->properties : []; + $appliedMetadata = []; + $warnings = []; + + foreach ($propertyMap as $bundleKey => $propertyKey) { + $value = $bundle[$bundleKey] ?? []; + if ($value === []) { + continue; + } + + $properties[$propertyKey] = $value; + $appliedMetadata[] = $bundleKey; + } + + if ($appliedMetadata === []) { + return ['applied_metadata' => [], 'warnings' => []]; + } + + McpSupport::ensureActingUser($process); + $this->platform()->updateProcess($process, ['properties' => $properties]); + $process->refresh(); + + return [ + 'applied_metadata' => $appliedMetadata, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $payload + * @return array{applied: array, warnings: array} + */ + public function applyWebEntry(string $processId, array $payload): array + { + if (($payload['web_entries'] ?? []) === []) { + return ['applied' => [], 'warnings' => []]; + } + + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $startNode = $xpath->query('//bpmn:startEvent')->item(0); + + if (!($startNode instanceof \DOMElement)) { + return ['applied' => [], 'warnings' => ['No start event found for web entry.']]; + } + + $screensByPm3 = $this->indexAssetsByPm3($payload['screens'] ?? []); + $applied = []; + $warnings = []; + + foreach ($payload['web_entries'] as $webEntry) { + if (!is_array($webEntry)) { + continue; + } + + $dynUid = $webEntry['dyn_uid'] ?? null; + $screenId = is_string($dynUid) ? ($screensByPm3[$dynUid] ?? null) : null; + if ($screenId === null) { + $warnings[] = 'Web entry screen was not migrated.'; + continue; + } + + $config = [ + 'web_entry' => [ + 'require_valid_session' => ($webEntry['we_authentication'] ?? '') !== 'guest', + 'screen_id' => (int) $screenId, + 'completed_action' => 'SCREEN', + 'completed_screen_id' => (int) $screenId, + 'mode' => 'NEW', + ], + ]; + + $this->setPmAttributeOnNode($startNode, 'config', json_encode($config)); + $applied[] = [ + 'we_uid' => $webEntry['we_uid'] ?? null, + 'screen_id' => $screenId, + ]; + } + + $this->saveProcessBpmn($process, $definitions); + + if (method_exists($process, 'manageCustomRoutes')) { + $process->manageCustomRoutes(); + } + + return ['applied' => $applied, 'warnings' => $warnings]; + } + + /** + * @param array $payload + * @return array{applied: array, warnings: array} + */ + public function applyStepTriggers(string $processId, array $payload): array + { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $taskElementMap = MigrationBpmn::buildTaskElementMap($payload, $xpath); + + $scriptsByPm3 = $this->indexAssetsByPm3($payload['scripts'] ?? []); + $applied = []; + $warnings = []; + $modified = false; + $counter = 1; + + foreach ($payload['steps'] ?? [] as $step) { + if (!is_array($step) || ($step['step_type'] ?? null) !== 'DYNAFORM') { + continue; + } + + $tasUid = $step['tas_uid'] ?? null; + if (!is_string($tasUid) || $tasUid === '') { + continue; + } + + $elementId = $taskElementMap[$tasUid] ?? null; + if ($elementId === null) { + continue; + } + + foreach ($step['triggers'] ?? [] as $trigger) { + if (!is_array($trigger)) { + continue; + } + + $triUid = $trigger['tri_uid'] ?? null; + $type = strtoupper((string) ($trigger['type'] ?? '')); + $scriptId = is_string($triUid) ? ($scriptsByPm3[$triUid] ?? null) : null; + + if ($scriptId === null) { + $warnings[] = "Step trigger {$triUid} was not migrated."; + continue; + } + + $scriptNodeId = 'step_script_' . $counter++; + if ($type === 'BEFORE') { + $this->insertStepScript($definitions, $xpath, $elementId, $scriptNodeId, $scriptId, 'before'); + } else { + $this->insertStepScript($definitions, $xpath, $elementId, $scriptNodeId, $scriptId, 'after'); + } + + $applied[] = [ + 'tas_uid' => $tasUid, + 'tri_uid' => $triUid, + 'type' => $type, + 'script_node_id' => $scriptNodeId, + ]; + $modified = true; + } + } + + if ($modified) { + $this->saveProcessBpmn($process, $definitions); + } + + return ['applied' => $applied, 'warnings' => $warnings]; + } + + private function buildLinkPayload(array $bundle, array $screens, array $scripts): array + { + return [ + 'screens' => $screens, + 'scripts' => $scripts, + 'steps' => $bundle['steps'] ?? [], + 'tasks' => $bundle['tasks'] ?? [], + 'script_tasks' => $bundle['script_tasks'] ?? [], + 'task_element_map' => $bundle['task_element_map'] ?? [], + ]; + } + + /** + * @param array $assets + * @return array + */ + private function indexAssetsByPm3(array $assets): array + { + $map = []; + + foreach ($assets as $asset) { + if (!is_array($asset)) { + continue; + } + + $pm3Uid = $asset['pm3_uid'] ?? null; + $pm4Id = $asset['pm4_id'] ?? null; + if (is_string($pm3Uid) && $pm3Uid !== '' && $pm4Id !== null) { + $map[$pm3Uid] = (string) $pm4Id; + } + } + + return $map; + } + + private function insertStepScript( + DOMDocument $definitions, + DOMXPath $xpath, + string $taskElementId, + string $scriptNodeId, + string $scriptId, + string $position + ): void { + $processNode = $xpath->query('//bpmn:process')->item(0); + if (!($processNode instanceof \DOMElement)) { + return; + } + + $scriptNode = $definitions->createElementNS('http://www.omg.org/spec/BPMN/20100524/MODEL', 'bpmn:scriptTask'); + $scriptNode->setAttribute('id', $scriptNodeId); + $scriptNode->setAttribute('name', 'Step Script'); + $this->setPmAttributeOnNode($scriptNode, 'scriptRef', $scriptId); + $processNode->appendChild($scriptNode); + + if ($position === 'before') { + foreach ($xpath->query("//bpmn:sequenceFlow[@targetRef='{$taskElementId}']") as $flowNode) { + if ($flowNode instanceof \DOMElement) { + $flowNode->setAttribute('targetRef', $scriptNodeId); + } + } + + $bridgeFlow = $definitions->createElementNS('http://www.omg.org/spec/BPMN/20100524/MODEL', 'bpmn:sequenceFlow'); + $bridgeFlow->setAttribute('id', $scriptNodeId . '_to_task'); + $bridgeFlow->setAttribute('sourceRef', $scriptNodeId); + $bridgeFlow->setAttribute('targetRef', $taskElementId); + $processNode->appendChild($bridgeFlow); + + return; + } + + $targets = []; + foreach ($xpath->query("//bpmn:sequenceFlow[@sourceRef='{$taskElementId}']") as $flowNode) { + if ($flowNode instanceof \DOMElement) { + $target = $flowNode->getAttribute('targetRef'); + if ($target !== '') { + $targets[] = $target; + } + $flowNode->setAttribute('targetRef', $scriptNodeId); + } + } + + foreach ($targets as $index => $targetId) { + $outFlow = $definitions->createElementNS('http://www.omg.org/spec/BPMN/20100524/MODEL', 'bpmn:sequenceFlow'); + $outFlow->setAttribute('id', $scriptNodeId . '_out_' . $index); + $outFlow->setAttribute('sourceRef', $scriptNodeId); + $outFlow->setAttribute('targetRef', $targetId); + $processNode->appendChild($outFlow); + } + } + + /** + * @param array $payload + * @return array + */ + public function applyAbeConfiguration(string $processId, array $payload): array + { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $taskElementMap = MigrationBpmn::buildTaskElementMap($payload, $xpath); + + $screensByPm3 = $this->indexAssetsByPm3($payload['screens'] ?? []); + + $emailServerMap = is_array($payload['email_server_map'] ?? null) + ? $payload['email_server_map'] + : []; + + $applied = []; + $warnings = []; + $modified = false; + + foreach ($payload['abe_configurations'] ?? [] as $abe) { + if (!is_array($abe)) { + continue; + } + + $tasUid = $abe['tas_uid'] ?? $abe['TAS_UID'] ?? null; + if (!is_string($tasUid) || $tasUid === '') { + continue; + } + + $elementId = $taskElementMap[$tasUid] ?? null; + if ($elementId === null) { + $warnings[] = "No BPMN element found for ABE task {$tasUid}."; + continue; + } + + $node = $xpath->query("//*[@id='{$elementId}']")->item(0); + if (!($node instanceof \DOMElement)) { + $warnings[] = "BPMN element {$elementId} not found for ABE task {$tasUid}."; + continue; + } + + $dynUid = $abe['dyn_uid'] ?? $abe['DYN_UID'] ?? null; + $screenEmailRef = is_string($dynUid) ? ($screensByPm3[$dynUid] ?? '') : ''; + + if ($screenEmailRef === '') { + $warnings[] = "ABE task {$tasUid}: email screen dynaform was not migrated."; + } + + $pm4Config = is_array($abe['pm4_config_email'] ?? null) ? $abe['pm4_config_email'] : []; + $emailServerUid = $abe['abe_email_server_uid'] ?? $abe['ABE_EMAIL_SERVER_UID'] ?? null; + $emailServer = is_string($emailServerUid) + ? (string) ($emailServerMap[$emailServerUid] ?? '') + : ''; + + if ($emailServer === '' && is_string($emailServerUid) && $emailServerUid !== '') { + $warnings[] = "ABE task {$tasUid}: PM3 email server {$emailServerUid} was not mapped to PM4."; + } + + $configEmail = [ + 'subject' => (string) ($pm4Config['subject'] + ?? $abe['abe_subject_field'] + ?? $abe['ABE_SUBJECT_FIELD'] + ?? 'RE: {{ task.name }}'), + 'requireLogin' => (bool) ($pm4Config['requireLogin'] + ?? $abe['abe_force_login'] + ?? $abe['ABE_FORCE_LOGIN'] + ?? false), + 'emailServer' => $emailServer, + 'screenEmailRef' => $screenEmailRef !== '' ? (int) $screenEmailRef : '', + 'email_notifications' => ['notifications' => []], + ]; + + $screenCompleteRef = $pm4Config['screenCompleteRef'] ?? ''; + if ($screenCompleteRef !== '' && $screenCompleteRef !== null) { + $configEmail['screenCompleteRef'] = $screenCompleteRef; + } + + $this->setPmAttributeOnNode($node, 'isActionsByEmail', 'true'); + $this->setPmAttributeOnNode($node, 'configEmail', json_encode($configEmail)); + + if (($abe['abe_type'] ?? $abe['ABE_TYPE'] ?? null) === 'CUSTOM') { + $warnings[] = "ABE task {$tasUid} uses CUSTOM type; verify PM4 email screen layout."; + } + + $applied[] = [ + 'tas_uid' => $tasUid, + 'element_id' => $elementId, + 'screen_email_ref' => $screenEmailRef, + ]; + $modified = true; + } + + if ($modified) { + $this->saveProcessBpmn($process, $definitions); + } + + return [ + 'process_id' => $process->id, + 'applied' => $applied, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $payload + * @return array + */ + public function applyTaskAssignments(string $processId, array $payload): array + { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $taskElementMap = MigrationBpmn::buildTaskElementMap($payload, $xpath); + + $userMap = is_array($payload['identity_map']['users'] ?? null) + ? $payload['identity_map']['users'] + : []; + $groupMap = is_array($payload['identity_map']['groups'] ?? null) + ? $payload['identity_map']['groups'] + : []; + + $applied = []; + $warnings = []; + $modified = false; + + foreach ($payload['task_assignments'] ?? [] as $assignment) { + if (!is_array($assignment)) { + continue; + } + + $tasUid = $assignment['tas_uid'] ?? null; + if (!is_string($tasUid) || $tasUid === '') { + continue; + } + + $elementId = $taskElementMap[$tasUid] ?? null; + if ($elementId === null) { + $warnings[] = "No BPMN element found for PM3 task {$tasUid}."; + continue; + } + + $node = $xpath->query("//*[@id='{$elementId}']")->item(0); + if (!($node instanceof \DOMElement)) { + $warnings[] = "BPMN element {$elementId} not found for task {$tasUid}."; + continue; + } + + $pm4Assignment = (string) ($assignment['pm4_assignment'] ?? 'user_group'); + $this->setPmAttributeOnNode($node, 'assignment', $pm4Assignment); + + if (in_array($pm4Assignment, ['user', 'group', 'user_group', 'self_service'], true)) { + $mappedUsers = $this->mapIdentityIds($assignment['user_uids'] ?? [], $userMap); + $mappedGroups = $this->mapIdentityIds($assignment['group_uids'] ?? [], $groupMap); + + if (($assignment['user_uids'] ?? []) !== [] && $mappedUsers === []) { + $warnings[] = "Task {$tasUid}: PM3 user assignments were not mapped to PM4 IDs."; + } + + if (($assignment['group_uids'] ?? []) !== [] && $mappedGroups === []) { + $warnings[] = "Task {$tasUid}: PM3 group assignments were not mapped to PM4 IDs."; + } + + $this->setPmAttributeOnNode($node, 'assignedUsers', implode(',', $mappedUsers)); + $this->setPmAttributeOnNode($node, 'assignedGroups', implode(',', $mappedGroups)); + } + + if (in_array($pm4Assignment, ['process_variable', 'rule_expression', 'user_by_id'], true)) { + $usersExpression = $this->convertPm3Variable($assignment['assign_variable'] ?? null); + $groupsExpression = $this->convertPm3Variable($assignment['group_variable'] ?? null); + + if ($usersExpression !== '') { + $this->setPmAttributeOnNode($node, 'assignedUsers', $usersExpression); + } + + if ($groupsExpression !== '') { + $this->setPmAttributeOnNode($node, 'assignedGroups', $groupsExpression); + } + } + + $applied[] = [ + 'tas_uid' => $tasUid, + 'element_id' => $elementId, + 'pm4_assignment' => $pm4Assignment, + ]; + $modified = true; + } + + if ($modified) { + $this->saveProcessBpmn($process, $definitions); + } + + return [ + 'process_id' => $process->id, + 'applied' => $applied, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $reportTable + * @param array $fields + * @return array{collection: Collection, warnings: array} + */ + public function createCollectionFromReportTable(array $reportTable, array $fields = []): array + { + if (!class_exists(Collection::class)) { + throw ValidationException::withMessages([ + 'collection' => ['package-collections is not installed.'], + ]); + } + + $title = $reportTable['rep_tab_title'] + ?? $reportTable['REP_TAB_TITLE'] + ?? $reportTable['rep_tab_name'] + ?? $reportTable['REP_TAB_NAME'] + ?? 'Migrated Collection'; + $warnings = []; + + if (!empty($reportTable['rep_tab_connection']) || !empty($reportTable['REP_TAB_CONNECTION'])) { + $warnings[] = 'External DB connection was not migrated; collection stores metadata only.'; + } + + $screens = $this->createMigrationCollectionScreens($title); + $userId = Auth::id() ?? User::firstOrFail()->id; + $columns = $this->buildCollectionColumns($fields); + + $collection = new Collection(); + $collection->fill([ + 'name' => $title, + 'description' => 'Migrated from PM3 report table ' + . ($reportTable['rep_tab_uid'] ?? $reportTable['REP_TAB_UID'] ?? 'unknown'), + 'create_screen_id' => $screens['create'], + 'read_screen_id' => $screens['read'], + 'update_screen_id' => $screens['update'], + 'created_by_id' => $userId, + 'updated_by_id' => $userId, + ]); + $collection->columns = $columns; + $collection->saveOrFail(); + + return [ + 'collection' => $collection, + 'warnings' => $warnings, + ]; + } + + /** + * @return array{process_id: string, valid: bool, errors: array} + */ + public function validateProcess(string $processId): array + { + $process = Process::findOrFail($processId); + $errors = MigrationBpmn::validate($process->bpmn); + + return [ + 'process_id' => $process->id, + 'valid' => $errors === [], + 'errors' => $errors, + ]; + } + + /** + * @param array|string $configJson + * @return array + */ + private function parseConfigJson(array|string $configJson): array + { + if (is_array($configJson)) { + return $configJson; + } + + $decoded = json_decode($configJson, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw ValidationException::withMessages([ + 'config_json' => ['Invalid JSON: ' . json_last_error_msg()], + ]); + } + + return is_array($decoded) ? $decoded : []; + } + + private function defaultScreenCategoryId(): int|string + { + return ScreenCategory::firstOrCreate( + ['name' => McpSupport::SCREEN_CATEGORY], + ['status' => 'ACTIVE'] + )->id; + } + + private function defaultProcessCategoryId(): int|string + { + return ProcessCategory::firstOrCreate( + ['name' => McpSupport::PROCESS_CATEGORY], + ['status' => 'ACTIVE'] + )->id; + } + + private function defaultScriptCategoryId(): int|string + { + return ScriptCategory::firstOrCreate( + ['name' => McpSupport::SCRIPT_CATEGORY], + ['status' => 'ACTIVE'] + )->id; + } + + private function resolveMigrationProcessName(string $title): string + { + $name = trim($title); + if ($name === '') { + $name = 'Migrated Process'; + } + + $name = preg_replace('/[^a-zA-Z0-9 _\-]+/', ' ', $name) ?? $name; + $name = trim(preg_replace('/\s+/', ' ', $name) ?? $name); + if ($name === '') { + $name = 'Migrated Process'; + } + + $base = $name; + $suffix = 2; + while (Process::where('name', $name)->exists()) { + $name = $base . ' ' . $suffix; + $suffix++; + } + + return $name; + } + + /** + * @param class-string $modelClass + */ + private function resolveUniqueAssetTitle(string $title, string $modelClass): string + { + $name = trim($title); + if ($name === '') { + $name = 'Migrated Asset'; + } + + $base = $name; + $suffix = 2; + while ($modelClass::where('title', $name)->exists()) { + $name = $base . ' ' . $suffix; + $suffix++; + } + + return $name; + } + + private function setBpmnElementAttribute( + string $processId, + string $elementId, + string $attribute, + string $value + ): Process { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + + $this->setPmAttribute($xpath, $elementId, $attribute, $value); + $this->saveProcessBpmn($process, $definitions); + + return $process; + } + + private function saveProcessBpmn(Process $process, DOMDocument $definitions): void + { + $this->platform()->persistProcessBpmn($process, $definitions->saveXML()); + } + + private function platform(): PlatformWriter + { + return $this->platformWriter ??= app(PlatformWriter::class); + } + + private function setPmAttribute(DOMXPath $xpath, string $elementId, string $attribute, string $value): void + { + $node = $xpath->query("//*[@id='{$elementId}']")->item(0); + + if (!($node instanceof \DOMElement)) { + throw ValidationException::withMessages([ + 'element_id' => ["Element not found in BPMN: {$elementId}"], + ]); + } + + $this->setPmAttributeOnNode($node, $attribute, $value); + } + + private function setPmAttributeOnNode(\DOMElement $node, string $attribute, string $value): void + { + $node->setAttributeNS( + WorkflowServiceProvider::PROCESS_MAKER_NS, + $attribute, + $value + ); + } + + /** + * @return array{users: array, groups: array} + */ + private function normalizeIdentityMap(mixed $map): array + { + if (!is_array($map)) { + return ['users' => [], 'groups' => []]; + } + + return [ + 'users' => is_array($map['users'] ?? null) ? $map['users'] : [], + 'groups' => is_array($map['groups'] ?? null) ? $map['groups'] : [], + ]; + } + + private function findBpmnTaskNode(DOMXPath $xpath, string $elementId): ?\DOMElement + { + foreach (['//bpmn:task', '//bpmn:userTask'] as $expression) { + $node = $xpath->query("{$expression}[@id='{$elementId}']")->item(0); + if ($node instanceof \DOMElement) { + return $node; + } + } + + return null; + } + + /** + * @param array $pm3Uids + * @param array $map + * @return array + */ + private function mapIdentityIds(array $pm3Uids, array $map): array + { + $mapped = []; + + foreach ($pm3Uids as $pm3Uid) { + if (!is_string($pm3Uid) || $pm3Uid === '') { + continue; + } + + $pm4Id = $map[$pm3Uid] ?? null; + if ($pm4Id === null || $pm4Id === '') { + continue; + } + + $mapped[] = (string) $pm4Id; + } + + return array_values(array_unique($mapped)); + } + + private function convertPm3Variable(mixed $variable): string + { + return (new TriggerTranslator())->pm3VariableToMustache($variable); + } + + /** + * @return array{create: int|string, read: int|string, update: int|string} + */ + private function createMigrationCollectionScreens(string $title): array + { + $config = [['name' => $title, 'items' => []]]; + + return [ + 'create' => $this->createScreen([ + 'title' => $title . ' Create', + 'config_json' => $config, + ])->id, + 'read' => $this->createScreen([ + 'title' => $title . ' Read', + 'config_json' => $config, + ])->id, + 'update' => $this->createScreen([ + 'title' => $title . ' Update', + 'config_json' => $config, + ])->id, + ]; + } + + /** + * @param array $fields + * @return array> + */ + private function buildCollectionColumns(array $fields): array + { + $columns = [ + [ + 'label' => '#', + 'field' => 'id', + 'sortable' => true, + 'default' => true, + 'format' => 'int', + 'mask' => null, + ], + [ + 'label' => 'Created', + 'field' => 'created_at', + 'sortable' => true, + 'default' => true, + 'format' => 'datetime', + 'mask' => null, + ], + ]; + + foreach ($fields as $field) { + if (!is_array($field)) { + continue; + } + + $name = $field['rep_var_field'] ?? $field['REP_VAR_FIELD'] ?? null; + if (!is_string($name) || $name === '') { + continue; + } + + $slug = $this->slugFieldName($name); + $columns[] = [ + 'label' => $field['rep_var_title'] ?? $field['REP_VAR_TITLE'] ?? $name, + 'field' => 'data.' . $slug, + 'sortable' => true, + 'default' => true, + 'format' => $this->mapReportVarFormat($field), + 'mask' => null, + ]; + } + + return $columns; + } + + /** + * @param array $field + */ + private function mapReportVarFormat(array $field): string + { + $type = strtolower((string) ($field['rep_var_type'] ?? $field['REP_VAR_TYPE'] ?? 'string')); + + return match ($type) { + 'integer', 'int' => 'int', + 'float', 'double', 'decimal' => 'float', + 'date' => 'date', + 'datetime' => 'datetime', + 'boolean', 'bool' => 'boolean', + default => 'string', + }; + } + + private function slugFieldName(string $value): string + { + $value = preg_replace('/[^a-zA-Z0-9_]+/', '_', $value) ?? 'field'; + $value = trim($value, '_'); + + return $value !== '' ? strtolower($value) : 'field'; + } + + /** + * @param array $stylesheets + * @param array> $fieldRegistry + */ + private function mergeCustomCss(?string $dynaformCss, array $stylesheets, array $fieldRegistry): ?string + { + $cssConverter = new CssScopeConverter(); + $chunks = []; + + if (is_string($dynaformCss) && trim($dynaformCss) !== '') { + $chunks[] = trim($dynaformCss); + } + + foreach ($stylesheets as $stylesheet) { + if (!is_array($stylesheet)) { + continue; + } + + $content = $stylesheet['content'] ?? null; + if (!is_string($content) || trim($content) === '') { + continue; + } + + $converted = $cssConverter->convert(trim($content), $fieldRegistry); + if (is_string($converted['css']) && $converted['css'] !== '') { + $chunks[] = $converted['css']; + } + } + + if ($chunks === []) { + return null; + } + + return implode("\n\n", array_unique($chunks)); + } +} diff --git a/ProcessMaker/Mcp/Migration/WriterServer.php b/ProcessMaker/Mcp/Migration/WriterServer.php new file mode 100644 index 0000000000..397eac3231 --- /dev/null +++ b/ProcessMaker/Mcp/Migration/WriterServer.php @@ -0,0 +1,101 @@ +addMethod('tools/list', ListAllTools::class); + } +} diff --git a/ProcessMaker/Mcp/Platform/PlatformWriter.php b/ProcessMaker/Mcp/Platform/PlatformWriter.php new file mode 100644 index 0000000000..26f5c5c971 --- /dev/null +++ b/ProcessMaker/Mcp/Platform/PlatformWriter.php @@ -0,0 +1,342 @@ + $data + */ + public function createProcess(array $data): Process + { + $payload = [ + 'name' => $data['name'], + 'description' => $data['description'] ?? $data['name'], + 'status' => $data['status'] ?? 'ACTIVE', + ]; + + if (!empty($data['process_category_id'])) { + $payload['process_category_id'] = $data['process_category_id']; + } + + if (isset($data['bpmn']) && is_string($data['bpmn'])) { + $payload['bpmn'] = $this->prepareBpmn($data['bpmn']); + } + + $this->actingUser(); + $request = $this->apiRequest('POST', '/api/1.0/processes', $payload); + + return $this->unwrapModel( + app(ProcessController::class)->store($request), + Process::class, + ); + } + + /** + * @param array $data + */ + public function updateProcess(Process $process, array $data): Process + { + $payload = array_filter([ + 'name' => $data['name'] ?? null, + 'description' => $data['description'] ?? null, + 'status' => $data['status'] ?? null, + 'process_category_id' => $data['process_category_id'] ?? null, + 'properties' => $data['properties'] ?? null, + ], fn ($value) => $value !== null); + + if ($payload === []) { + return $process; + } + + $user = $this->actingUser(); + + $request = ProcessUpdateRequest::create( + '/api/1.0/processes/' . $process->id, + 'PUT', + $payload, + ); + $request->setUserResolver(fn (): User => $user); + $request->setRouteResolver(fn (): Route => $this->routeWithProcess($request, $process)); + + $response = app(ProcessController::class)->update($request, $process); + + if (is_array($response) && isset($response['error'])) { + throw ValidationException::withMessages(['process' => [(string) $response['error']]]); + } + + return $process->refresh(); + } + + public function updateProcessBpmn(Process $process, string $bpmn): Process + { + $prepared = $this->prepareBpmn($bpmn); + + $errors = MigrationBpmn::validate($prepared); + if ($errors !== []) { + throw ValidationException::withMessages(['bpmn' => $errors]); + } + + return $this->persistProcessBpmn($process, $prepared); + } + + public function persistProcessBpmn(Process $process, string $bpmn): Process + { + $process->refresh(); + + $request = $this->apiRequest('PUT', '/api/1.0/processes/' . $process->id . '/update-bpmn', [ + 'bpmn' => $this->prepareBpmn($bpmn), + 'name' => $process->name, + 'description' => $process->description, + ]); + + $response = app(ProcessController::class)->updateBpmn($request, $process); + + if ($response instanceof JsonResponse && $response->getStatusCode() >= 400) { + $this->throwValidationFromResponse($response); + } + + return $process->refresh(); + } + + /** + * @param array $data + */ + public function createScreen(array $data): Screen + { + return $this->createScreenResult($data)->screen; + } + + /** + * @param array $data + */ + public function createScreenResult(array $data): ScreenCreationResult + { + $import = $this->importScreen($data); + + if ($import['id'] !== null) { + $screen = Screen::findOrFail($import['id']); + if (!empty($data['screen_category_id'])) { + $screen->screen_category_id = $data['screen_category_id']; + $screen->saveOrFail(); + } + + return new ScreenCreationResult($screen, 'import_screen', $import['warnings']); + } + + $warnings = array_merge($import['warnings'], [ + 'Screen import failed; created screen via ScreenController::store fallback.', + ]); + + return new ScreenCreationResult( + $this->createScreenViaController($data), + 'screen_controller', + $warnings, + ); + } + + /** + * @param array $data + */ + public function createScript(array $data): Script + { + $user = $this->actingUser(); + + $payload = [ + 'title' => $data['title'], + 'description' => $data['description'] ?? $data['title'], + 'code' => $data['code'], + 'language' => $data['language'] ?? 'php', + 'run_as_user_id' => $data['run_as_user_id'] ?? $user->id, + 'script_category_id' => $data['script_category_id'] ?? null, + ]; + + $request = $this->apiRequest('POST', '/api/1.0/scripts', array_filter($payload, fn ($v) => $v !== null)); + + return $this->unwrapModel( + app(ScriptController::class)->store($request), + Script::class, + ); + } + + /** + * @param array $data + * @return array{id: int|string|null, warnings: array} + */ + private function importScreen(array $data): array + { + try { + $packageJson = $this->screenPackages->encode($data); + } catch (\JsonException $e) { + return [ + 'id' => null, + 'warnings' => ['Screen package encoding failed: ' . $e->getMessage()], + ]; + } + + try { + $result = (new ImportScreen($packageJson))->handle(); + } catch (\Throwable $e) { + return [ + 'id' => null, + 'warnings' => ['ImportScreen failed: ' . $e->getMessage()], + ]; + } + + if (!is_array($result)) { + return [ + 'id' => null, + 'warnings' => ['ImportScreen returned an invalid response.'], + ]; + } + + $screenId = $result['screens']['id'] ?? null; + + if ($screenId === null || $screenId === false) { + return [ + 'id' => null, + 'warnings' => ['ImportScreen did not return a screen ID.'], + ]; + } + + return ['id' => $screenId, 'warnings' => []]; + } + + /** + * @param array $data + */ + private function createScreenViaController(array $data): Screen + { + $config = $data['config'] ?? $data['config_json'] ?? []; + + $payload = [ + 'title' => $data['title'], + 'description' => $data['description'] ?? $data['title'], + 'type' => $data['type'] ?? 'FORM', + 'config' => is_array($config) ? $config : [], + 'computed' => $data['computed'] ?? [], + 'watchers' => $data['watchers'] ?? [], + 'custom_css' => $data['custom_css'] ?? '', + 'screen_category_id' => $data['screen_category_id'] ?? null, + ]; + + $request = $this->apiRequest('POST', '/api/1.0/screens', array_filter($payload, fn ($v) => $v !== null)); + + return $this->unwrapModel( + app(ScreenController::class)->store($request), + Screen::class, + ); + } + + /** + * @param array $payload + */ + private function apiRequest(string $method, string $uri, array $payload = []): Request + { + $request = Request::create($uri, $method, $payload); + $request->setUserResolver(fn (): User => $this->actingUser()); + + return $request; + } + + private function actingUser(): User + { + if (Auth::user() instanceof User) { + return Auth::user(); + } + + $user = User::firstOrFail(); + Auth::setUser($user); + + return $user; + } + + private function prepareBpmn(string $bpmn): string + { + return BpmnDocument::replaceHtmlEntities(FixBpmnSchemaService::fix($bpmn)); + } + + /** + * @param class-string $modelClass + */ + private function unwrapModel(mixed $response, string $modelClass): mixed + { + if ($response instanceof JsonResponse) { + $this->throwValidationFromResponse($response); + } + + if ($response instanceof \Symfony\Component\HttpFoundation\Response && !($response instanceof JsonResponse)) { + if ($response->getStatusCode() >= 400) { + $payload = json_decode($response->getContent(), true); + if (is_array($payload)) { + $errors = is_array($payload['errors'] ?? null) ? $payload['errors'] : []; + if ($errors === [] && isset($payload['message'])) { + $errors = ['message' => [(string) $payload['message']]]; + } + throw ValidationException::withMessages($errors); + } + } + } + + if ($response instanceof JsonResource) { + $model = $response->resource; + if ($model instanceof $modelClass) { + return $model->refresh(); + } + } + + throw ValidationException::withMessages([ + 'platform' => ['Unexpected response from PM4 platform writer.'], + ]); + } + + private function throwValidationFromResponse(JsonResponse $response): never + { + $payload = $response->getData(true); + $errors = is_array($payload['errors'] ?? null) ? $payload['errors'] : []; + + if ($errors === [] && isset($payload['message'])) { + $errors = ['message' => [(string) $payload['message']]]; + } + + throw ValidationException::withMessages($errors); + } + + private function routeWithProcess(ProcessUpdateRequest $request, Process $process): Route + { + $route = new Route('PUT', '/api/1.0/processes/{process}', []); + $route->bind($request); + $route->setParameter('process', $process); + + return $route; + } +} diff --git a/ProcessMaker/Mcp/Platform/ScreenCreationResult.php b/ProcessMaker/Mcp/Platform/ScreenCreationResult.php new file mode 100644 index 0000000000..f0bbc5fc23 --- /dev/null +++ b/ProcessMaker/Mcp/Platform/ScreenCreationResult.php @@ -0,0 +1,32 @@ + $warnings + */ + public function __construct( + public readonly Screen $screen, + public readonly string $importMethod, + public readonly array $warnings = [], + ) { + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'screen' => $this->screen, + 'import_method' => $this->importMethod, + 'warnings' => $this->warnings, + ]; + } +} diff --git a/ProcessMaker/Mcp/Platform/ScreenPackageBuilder.php b/ProcessMaker/Mcp/Platform/ScreenPackageBuilder.php new file mode 100644 index 0000000000..13dc08b109 --- /dev/null +++ b/ProcessMaker/Mcp/Platform/ScreenPackageBuilder.php @@ -0,0 +1,50 @@ + $data + */ + public function encode(array $data): string + { + $package = [ + 'type' => 'screen_package', + 'version' => '2', + 'screens' => [$this->screenObject($data)], + ]; + + return json_encode($package, JSON_THROW_ON_ERROR); + } + + /** + * @param array $data + * @return array + */ + private function screenObject(array $data): array + { + $config = $data['config'] ?? $data['config_json'] ?? []; + + return [ + 'id' => 'mcp_' . Str::uuid()->toString(), + 'title' => (string) ($data['title'] ?? 'Screen'), + 'description' => (string) ($data['description'] ?? $data['title'] ?? 'Screen'), + 'type' => (string) ($data['type'] ?? 'FORM'), + 'config' => is_array($config) ? $config : [], + 'computed' => is_array($data['computed'] ?? null) ? $data['computed'] : [], + 'watchers' => is_array($data['watchers'] ?? null) ? $data['watchers'] : [], + 'custom_css' => (string) ($data['custom_css'] ?? ''), + 'created_at' => now()->toIso8601String(), + 'updated_at' => now()->toIso8601String(), + 'categories' => [], + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Analyzer.php b/ProcessMaker/Mcp/Process/Analyzer.php new file mode 100644 index 0000000000..115cf89cd9 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Analyzer.php @@ -0,0 +1,167 @@ +>, + * suggestions: array, + * validation: array + * } + */ + public function analyze(string $processId): array + { + $process = Process::findOrFail($processId); + $inventory = $this->reader->getProcessInventory($processId); + $validation = $this->writer->validateProcess($processId); + $findings = []; + $suggestions = []; + + if (($validation['valid'] ?? false) !== true) { + $findings[] = [ + 'severity' => 'error', + 'code' => 'bpmn_invalid', + 'message' => 'BPMN validation failed.', + 'details' => $validation['errors'] ?? [], + ]; + $suggestions[] = 'Fix BPMN validation errors with update_process_bpmn or bpmn design tools.'; + } + + foreach ($inventory['linked_assets']['tasks_without_screen'] ?? [] as $task) { + $findings[] = [ + 'severity' => 'warning', + 'code' => 'task_without_screen', + 'message' => 'Task "' . ($task['name'] ?: $task['element_id']) . '" has no screen linked.', + 'element_id' => $task['element_id'], + ]; + $suggestions[] = 'Link a screen to element ' . $task['element_id'] . ' with link_screen_to_task or create_screen_from_fields.'; + } + + foreach ($inventory['linked_assets']['screens'] ?? [] as $linkedScreen) { + $screenId = (string) ($linkedScreen['screen_id'] ?? ''); + if ($screenId === '') { + continue; + } + + $screen = Screen::find($screenId); + if ($screen === null) { + $findings[] = [ + 'severity' => 'error', + 'code' => 'linked_screen_not_found', + 'message' => 'Linked screen ID ' . $screenId . ' was not found.', + 'screen_id' => $screenId, + ]; + continue; + } + + $validation = $this->screenBuilder->validateScreenConfig(is_array($screen->config) ? $screen->config : []); + foreach ($validation['issues'] as $issue) { + $findings[] = [ + 'severity' => $issue['severity'] ?? 'warning', + 'code' => $issue['code'] ?? 'screen_config_issue', + 'message' => ($issue['message'] ?? 'Screen config issue') . ' (screen "' . $screen->title . '" #' . $screenId . ')', + 'screen_id' => $screenId, + 'path' => $issue['path'] ?? null, + ]; + } + + if (($validation['valid'] ?? false) !== true) { + $suggestions[] = 'Fix screen "' . $screen->title . '" (#' . $screenId . '): recreate with create_screen_from_fields or update_screen when available.'; + } + } + + foreach ($inventory['linked_assets']['script_tasks'] ?? [] as $scriptTask) { + if ($scriptTask['script_id'] === null) { + $findings[] = [ + 'severity' => 'warning', + 'code' => 'script_task_without_script', + 'message' => 'Script task "' . ($scriptTask['name'] ?: $scriptTask['element_id']) . '" has no scriptRef.', + 'element_id' => $scriptTask['element_id'], + ]; + $suggestions[] = 'Create a script and link it with link_script_to_task for ' . $scriptTask['element_id'] . '.'; + } + } + + if (($inventory['counts']['tasks'] ?? 0) === 0 && ($inventory['counts']['script_tasks'] ?? 0) === 0) { + $findings[] = [ + 'severity' => 'info', + 'code' => 'no_human_tasks', + 'message' => 'Process has no user tasks; it may be start-to-end only or script-only.', + ]; + $suggestions[] = 'Add user tasks with add_bpmn_user_task to build a workable approval/data flow.'; + } + + if (($inventory['counts']['variables'] ?? 0) === 0) { + $findings[] = [ + 'severity' => 'info', + 'code' => 'no_variables', + 'message' => 'No process variables defined in process.properties.', + ]; + $suggestions[] = 'Add variables with apply_process_variables if the process needs request data defaults.'; + } + + if ($process->status !== 'ACTIVE') { + $findings[] = [ + 'severity' => 'warning', + 'code' => 'process_not_active', + 'message' => 'Process status is ' . $process->status . '; new requests may not be startable.', + ]; + $suggestions[] = 'Set status to ACTIVE with update_process when ready for use.'; + } + + if ($process->categories()->count() === 0) { + $findings[] = [ + 'severity' => 'error', + 'code' => 'missing_process_category', + 'message' => 'Process has no category; it will not appear in Launchpad (All Processes).', + ]; + $suggestions[] = 'Assign process_category_id or recreate with MCP after ensureProcessIsStartable fix.'; + } + + if ($process->usersCanStart()->count() === 0 && $process->groupsCanStart()->count() === 0) { + $findings[] = [ + 'severity' => 'error', + 'code' => 'missing_start_permissions', + 'message' => 'No users or groups can start this process (non-admin users cannot start it).', + ]; + $suggestions[] = 'Configure start event assignment in BPMN or call ensureProcessIsStartable via Writer/MCP recreate.'; + } + + $errorCount = count(array_filter($findings, fn (array $f): bool => ($f['severity'] ?? '') === 'error')); + $warningCount = count(array_filter($findings, fn (array $f): bool => ($f['severity'] ?? '') === 'warning')); + $linkedScreenCount = count($inventory['linked_assets']['screens'] ?? []); + $totalChecks = max(1, ($inventory['counts']['tasks'] ?? 0) + $linkedScreenCount + 3); + $score = (int) max(0, min(100, round((1 - ($errorCount * 2 + $warningCount) / $totalChecks) * 100))); + + return [ + 'process_id' => $process->id, + 'name' => $process->name, + 'score' => $score, + 'findings' => $findings, + 'suggestions' => array_values(array_unique($suggestions)), + 'validation' => $validation, + 'inventory' => [ + 'counts' => $inventory['counts'], + 'tasks_without_screen' => count($inventory['linked_assets']['tasks_without_screen'] ?? []), + ], + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/BpmnDesigner.php b/ProcessMaker/Mcp/Process/BpmnDesigner.php new file mode 100644 index 0000000000..f10c4c913d --- /dev/null +++ b/ProcessMaker/Mcp/Process/BpmnDesigner.php @@ -0,0 +1,272 @@ +} + */ + public function addUserTask( + string $processId, + string $name, + ?string $elementId = null, + ?string $insertAfterElementId = null, + ): array { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $processNode = $xpath->query('//bpmn:process')->item(0); + + if (!($processNode instanceof DOMElement)) { + throw ValidationException::withMessages(['bpmn' => ['No process element found in BPMN.']]); + } + + $taskId = $elementId ?? 'node_' . Str::random(8); + if ($xpath->query("//*[@id='{$taskId}']")->item(0) !== null) { + throw ValidationException::withMessages(['element_id' => ["Element ID already exists: {$taskId}"]]); + } + + $task = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'userTask'); + $task->setAttribute('id', $taskId); + $task->setAttribute('name', $name); + $processNode->appendChild($task); + + $flowIds = []; + + if ($insertAfterElementId !== null) { + $flowIds = $this->insertTaskAfterElement($definitions, $xpath, $processNode, $task, $insertAfterElementId); + } else { + $flowIds = $this->wireTaskBeforeEnd($definitions, $xpath, $processNode, $task); + } + + $this->writer->updateProcessBpmn($processId, $this->prepareBpmnXml($definitions)); + + return [ + 'process_id' => $processId, + 'element_id' => $taskId, + 'name' => $name, + 'flows' => $flowIds, + ]; + } + + /** + * @return array{process_id: string, flow_id: string, source_ref: string, target_ref: string} + */ + public function addSequenceFlow( + string $processId, + string $sourceRef, + string $targetRef, + ?string $flowId = null, + ?string $condition = null, + ): array { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $processNode = $xpath->query('//bpmn:process')->item(0); + + if (!($processNode instanceof DOMElement)) { + throw ValidationException::withMessages(['bpmn' => ['No process element found in BPMN.']]); + } + + if ($xpath->query("//*[@id='{$sourceRef}']")->item(0) === null) { + throw ValidationException::withMessages(['source_ref' => ["Source element not found: {$sourceRef}"]]); + } + if ($xpath->query("//*[@id='{$targetRef}']")->item(0) === null) { + throw ValidationException::withMessages(['target_ref' => ["Target element not found: {$targetRef}"]]); + } + + $flowId = $flowId ?? 'flow_' . Str::random(8); + $flow = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'sequenceFlow'); + $flow->setAttribute('id', $flowId); + $flow->setAttribute('sourceRef', $sourceRef); + $flow->setAttribute('targetRef', $targetRef); + + if ($condition !== null && $condition !== '') { + $flow->setAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'conditionalFlow', 'true'); + $flow->setAttribute('name', $condition); + } + + $processNode->appendChild($flow); + $source = $xpath->query("//*[@id='{$sourceRef}']")->item(0); + $target = $xpath->query("//*[@id='{$targetRef}']")->item(0); + if ($source instanceof DOMElement) { + $this->appendChildRef($source, 'outgoing', $flowId); + } + if ($target instanceof DOMElement) { + $this->appendChildRef($target, 'incoming', $flowId); + } + + $this->writer->updateProcessBpmn($processId, $this->prepareBpmnXml($definitions)); + + return [ + 'process_id' => $processId, + 'flow_id' => $flowId, + 'source_ref' => $sourceRef, + 'target_ref' => $targetRef, + ]; + } + + /** + * @return array{process_id: string, element_id: string, name: string} + */ + public function addExclusiveGateway( + string $processId, + string $name, + ?string $elementId = null, + ): array { + $process = Process::findOrFail($processId); + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $processNode = $xpath->query('//bpmn:process')->item(0); + + if (!($processNode instanceof DOMElement)) { + throw ValidationException::withMessages(['bpmn' => ['No process element found in BPMN.']]); + } + + $gatewayId = $elementId ?? 'gateway_' . Str::random(8); + $gateway = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'exclusiveGateway'); + $gateway->setAttribute('id', $gatewayId); + $gateway->setAttribute('name', $name); + $processNode->appendChild($gateway); + + $this->writer->updateProcessBpmn($processId, $this->prepareBpmnXml($definitions)); + + return [ + 'process_id' => $processId, + 'element_id' => $gatewayId, + 'name' => $name, + ]; + } + + private function prepareBpmnXml(DOMDocument $definitions): string + { + return BpmnDiagramInterchange::rebuild($definitions->saveXML() ?: ''); + } + + /** + * Insert a new task on the flow that currently reaches the end event. + * + * @return array + */ + private function wireTaskBeforeEnd( + DOMDocument $definitions, + \DOMXPath $xpath, + DOMElement $processNode, + DOMElement $task, + ): array { + $end = $xpath->query('//bpmn:endEvent')->item(0); + if (!($end instanceof DOMElement)) { + throw ValidationException::withMessages([ + 'bpmn' => ['No end event found. Set bpmn_template to SingleTask or provide insert_after_element_id.'], + ]); + } + + $endId = $end->getAttribute('id'); + $incomingFlow = $xpath->query("//bpmn:sequenceFlow[@targetRef='{$endId}']")->item(0); + if (!($incomingFlow instanceof DOMElement)) { + throw ValidationException::withMessages([ + 'bpmn' => ['No sequence flow reaches the end event.'], + ]); + } + + $flowId = $incomingFlow->getAttribute('id'); + $taskId = $task->getAttribute('id'); + + $incomingFlow->setAttribute('targetRef', $taskId); + $this->removeIncomingReference($xpath, $endId, $flowId); + $this->appendChildRef($task, 'incoming', $flowId); + + $newFlowId = 'flow_' . Str::random(8); + $newFlow = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'sequenceFlow'); + $newFlow->setAttribute('id', $newFlowId); + $newFlow->setAttribute('sourceRef', $taskId); + $newFlow->setAttribute('targetRef', $endId); + $processNode->appendChild($newFlow); + + $this->appendChildRef($task, 'outgoing', $newFlowId); + $this->appendChildRef($end, 'incoming', $newFlowId); + + return [$flowId, $newFlowId]; + } + + /** + * @return array + */ + private function insertTaskAfterElement( + DOMDocument $definitions, + \DOMXPath $xpath, + DOMElement $processNode, + DOMElement $task, + string $afterElementId, + ): array { + $outgoingFlow = $xpath->query("//bpmn:sequenceFlow[@sourceRef='{$afterElementId}']")->item(0); + if (!($outgoingFlow instanceof DOMElement)) { + throw ValidationException::withMessages([ + 'insert_after_element_id' => ["No outgoing flow from element: {$afterElementId}"], + ]); + } + + $oldTarget = $outgoingFlow->getAttribute('targetRef'); + $taskId = $task->getAttribute('id'); + + $flow1Id = $outgoingFlow->getAttribute('id'); + $outgoingFlow->setAttribute('targetRef', $taskId); + $this->removeIncomingReference($xpath, $oldTarget, $flow1Id); + $this->appendChildRef($task, 'incoming', $flow1Id); + + $flow2 = $definitions->createElementNS(MigrationBpmn::BPMN_NS, 'sequenceFlow'); + $flow2Id = 'flow_' . Str::random(8); + $flow2->setAttribute('id', $flow2Id); + $flow2->setAttribute('sourceRef', $taskId); + $flow2->setAttribute('targetRef', $oldTarget); + $processNode->appendChild($flow2); + + $this->appendChildRef($task, 'outgoing', $flow2Id); + $targetNode = $xpath->query("//*[@id='{$oldTarget}']")->item(0); + if ($targetNode instanceof DOMElement) { + $this->appendChildRef($targetNode, 'incoming', $flow2Id); + } + + return [$flow1Id, $flow2Id]; + } + + private function appendChildRef(DOMElement $node, string $tag, string $flowId): void + { + $ref = $node->ownerDocument->createElementNS(MigrationBpmn::BPMN_NS, $tag); + $ref->textContent = $flowId; + $node->appendChild($ref); + } + + private function removeIncomingReference(\DOMXPath $xpath, string $elementId, string $flowId): void + { + $node = $xpath->query("//*[@id='{$elementId}']")->item(0); + if (!($node instanceof DOMElement)) { + return; + } + + foreach ($xpath->query('.//bpmn:incoming', $node) as $incoming) { + if ($incoming->textContent === $flowId) { + $node->removeChild($incoming); + + return; + } + } + } +} diff --git a/ProcessMaker/Mcp/Process/Designer.php b/ProcessMaker/Mcp/Process/Designer.php new file mode 100644 index 0000000000..dfa7e79cc2 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Designer.php @@ -0,0 +1,206 @@ + $plan + * @return array + */ + public function applyDesignPlan(array $plan): array + { + $processData = is_array($plan['process'] ?? null) ? $plan['process'] : []; + $bpmnTemplate = (string) ($plan['bpmn_template'] ?? 'SingleTask'); + + $processPayload = [ + 'name' => $processData['name'] ?? 'New Process', + 'description' => $processData['description'] ?? $processData['name'] ?? 'Created via MCP agent', + 'status' => $processData['status'] ?? 'ACTIVE', + 'process_category_id' => $processData['process_category_id'] ?? null, + 'bpmn' => $this->loadBpmnTemplate($bpmnTemplate), + ]; + + $process = $this->writer->createProcess(array_filter($processPayload, fn ($v) => $v !== null)); + $processId = (string) $process->id; + + $linkedScripts = []; + $addedTasks = []; + + foreach ($plan['tasks'] ?? [] as $taskPlan) { + if (!is_array($taskPlan)) { + continue; + } + + $added = $this->bpmnDesigner->addUserTask( + $processId, + (string) ($taskPlan['name'] ?? 'Task'), + is_string($taskPlan['element_id'] ?? null) ? $taskPlan['element_id'] : null, + is_string($taskPlan['insert_after_element_id'] ?? null) ? $taskPlan['insert_after_element_id'] : null, + ); + + $addedTasks[] = [ + 'element_id' => $added['element_id'], + 'screen_index' => $taskPlan['screen_index'] ?? null, + ]; + } + + $screens = []; + foreach ($plan['screens'] ?? [] as $screenPlan) { + if (!is_array($screenPlan)) { + continue; + } + + $title = (string) ($screenPlan['title'] ?? 'Form'); + $config = isset($screenPlan['config_json']) + ? $screenPlan['config_json'] + : $this->screenBuilder->buildFormConfig($screenPlan['fields'] ?? [], $title); + + $screen = $this->writer->createScreen([ + 'title' => $screenPlan['title'] ?? 'Form', + 'description' => $screenPlan['description'] ?? $screenPlan['title'] ?? 'Form', + 'config_json' => $config, + ]); + + $screens[] = [ + 'id' => $screen->id, + 'title' => $screen->title, + 'link_to_element' => $screenPlan['link_to_element'] ?? null, + ]; + } + + $scripts = []; + foreach ($plan['scripts'] ?? [] as $scriptPlan) { + if (!is_array($scriptPlan)) { + continue; + } + + $script = $this->writer->createScript([ + 'title' => $scriptPlan['title'] ?? 'Script', + 'description' => $scriptPlan['description'] ?? $scriptPlan['title'] ?? 'Script', + 'code' => $scriptPlan['code'] ?? ' $scriptPlan['language'] ?? 'php', + ]); + + $scripts[] = [ + 'id' => $script->id, + 'title' => $script->title, + 'link_to_element' => $scriptPlan['link_to_element'] ?? null, + ]; + } + + $linkedScreens = []; + foreach ($screens as $screen) { + $elementId = $screen['link_to_element'] ?? $this->defaultUserTaskElementId($processId); + if ($elementId === null) { + continue; + } + + $this->writer->linkScreenToTask($processId, $elementId, (string) $screen['id']); + $linkedScreens[] = [ + 'screen_id' => $screen['id'], + 'element_id' => $elementId, + ]; + } + + foreach ($scripts as $script) { + if ($script['link_to_element'] === null) { + continue; + } + + $this->writer->linkScriptToTask($processId, (string) $script['link_to_element'], (string) $script['id']); + $linkedScripts[] = [ + 'script_id' => $script['id'], + 'element_id' => $script['link_to_element'], + ]; + } + + $variableResult = ['applied' => [], 'warnings' => []]; + if (($plan['variables'] ?? []) !== []) { + $process->refresh(); + $variableResult = (new ProcessVariableMigrator())->apply($process, $plan['variables']); + } + + foreach ($addedTasks as $addedTask) { + if ($addedTask['screen_index'] === null || !isset($screens[(int) $addedTask['screen_index']])) { + continue; + } + + $screenId = (string) $screens[(int) $addedTask['screen_index']]['id']; + $this->writer->linkScreenToTask($processId, (string) $addedTask['element_id'], $screenId); + $linkedScreens[] = ['screen_id' => $screenId, 'element_id' => $addedTask['element_id']]; + } + + $validation = $this->writer->validateProcess($processId); + $analysis = $this->analyzer->analyze($processId); + + return [ + 'process_id' => $process->id, + 'process_name' => $process->name, + 'screens' => $screens, + 'scripts' => $scripts, + 'linked_screens' => $linkedScreens, + 'linked_scripts' => $linkedScripts, + 'applied_variables' => $variableResult['applied'] ?? [], + 'warnings' => $variableResult['warnings'] ?? [], + 'validation' => $validation, + 'analysis' => [ + 'score' => $analysis['score'], + 'findings_count' => count($analysis['findings']), + 'suggestions' => $analysis['suggestions'], + ], + ]; + } + + /** + * @param array> $variables + * @return array{applied: array, warnings: array} + */ + public function applyVariables(string $processId, array $variables): array + { + $process = Process::findOrFail($processId); + + return (new ProcessVariableMigrator())->apply($process, $variables); + } + + private function loadBpmnTemplate(string $template): string + { + $file = match ($template) { + 'OnlyStartElement', 'start_only' => 'OnlyStartElement.bpmn', + 'Collaboration' => 'Collaboration.bpmn', + default => 'SingleTask.bpmn', + }; + + $path = database_path('processes/templates/' . $file); + if (!is_readable($path)) { + return Process::getProcessTemplate('SingleTask.bpmn'); + } + + return file_get_contents($path); + } + + private function defaultUserTaskElementId(string $processId): ?string + { + $process = Process::findOrFail($processId); + $xpath = \ProcessMaker\Mcp\Migration\MigrationBpmn::createXPath($process->getDefinitions(true)); + $task = $xpath->query('//bpmn:userTask[@id] | //bpmn:task[@id]')->item(0); + + return $task instanceof \DOMElement ? $task->getAttribute('id') : null; + } +} diff --git a/ProcessMaker/Mcp/Process/Reader.php b/ProcessMaker/Mcp/Process/Reader.php new file mode 100644 index 0000000000..c151f9d562 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Reader.php @@ -0,0 +1,300 @@ +>, total: int} + */ + public function listProcesses(int $start = 0, int $limit = 25, ?string $filterName = null, ?string $status = 'ACTIVE'): array + { + $query = Process::nonSystem()->orderBy('name'); + + $status = strtoupper($status ?? 'ACTIVE'); + + if ($status === 'ARCHIVED') { + $query = Process::archived()->orderBy('name'); + } elseif ($status !== 'ALL') { + $query->where('status', $status); + } + + if ($filterName !== null && $filterName !== '') { + $query->filter($filterName); + } + + $total = (clone $query)->count(); + $processes = $query->skip($start)->take($limit)->get(['id', 'name', 'description', 'status', 'process_category_id', 'updated_at']); + + return [ + 'processes' => $processes->map(fn (Process $process): array => [ + 'id' => $process->id, + 'name' => $process->name, + 'description' => $process->description, + 'status' => $process->status, + 'process_category_id' => $process->process_category_id, + 'updated_at' => $process->updated_at?->toIso8601String(), + ])->all(), + 'total' => $total, + ]; + } + + /** + * @return array + */ + public function getProcess(string $processId): array + { + $process = Process::findOrFail($processId); + $linked = $this->extractLinkedAssets($process); + + return [ + 'id' => $process->id, + 'name' => $process->name, + 'description' => $process->description, + 'status' => $process->status, + 'process_category_id' => $process->process_category_id, + 'properties' => is_array($process->properties) ? $process->properties : [], + 'linked_assets' => $linked, + 'updated_at' => $process->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + public function getProcessInventory(string $processId): array + { + $process = Process::findOrFail($processId); + $linked = $this->extractLinkedAssets($process); + $properties = is_array($process->properties) ? $process->properties : []; + + return [ + 'process_id' => $process->id, + 'name' => $process->name, + 'status' => $process->status, + 'counts' => [ + 'tasks' => count($linked['tasks']), + 'script_tasks' => count($linked['script_tasks']), + 'gateways' => count($linked['gateways']), + 'linked_screens' => count($linked['screens']), + 'linked_scripts' => count($linked['scripts']), + 'variables' => count($properties['variables'] ?? []), + 'start_events' => count($linked['start_events']), + 'end_events' => count($linked['end_events']), + ], + 'linked_assets' => $linked, + 'variables' => $properties['variables'] ?? [], + ]; + } + + /** + * @return array{process_id: string, bpmn: string} + */ + public function getProcessBpmn(string $processId): array + { + $process = Process::findOrFail($processId); + + return [ + 'process_id' => $process->id, + 'bpmn' => $process->bpmn, + ]; + } + + /** + * @return array{screens: array>, total: int} + */ + public function listScreens(int $start = 0, int $limit = 25, ?string $filter = null): array + { + $query = Screen::nonSystem()->orderBy('title'); + + if ($filter !== null && $filter !== '') { + $query->filter($filter); + } + + $total = (clone $query)->count(); + + return [ + 'screens' => $query->skip($start)->take($limit)->get(['id', 'title', 'type', 'description', 'updated_at']) + ->map(fn (Screen $screen): array => [ + 'id' => $screen->id, + 'title' => $screen->title, + 'type' => $screen->type, + 'description' => $screen->description, + ])->all(), + 'total' => $total, + ]; + } + + /** + * @return array + */ + public function getScreen(string $screenId): array + { + $screen = Screen::findOrFail($screenId); + + return [ + 'id' => $screen->id, + 'title' => $screen->title, + 'type' => $screen->type, + 'description' => $screen->description, + 'config' => $screen->config, + 'custom_css' => $screen->custom_css, + ]; + } + + /** + * @return array{scripts: array>, total: int} + */ + public function listScripts(int $start = 0, int $limit = 25, ?string $filter = null): array + { + $query = Script::nonSystem()->orderBy('title'); + + if ($filter !== null && $filter !== '') { + $query->filter($filter); + } + + $total = (clone $query)->count(); + + return [ + 'scripts' => $query->skip($start)->take($limit)->get(['id', 'title', 'language', 'description', 'updated_at']) + ->map(fn (Script $script): array => [ + 'id' => $script->id, + 'title' => $script->title, + 'language' => $script->language, + 'description' => $script->description, + ])->all(), + 'total' => $total, + ]; + } + + /** + * @return array + */ + public function getScript(string $scriptId): array + { + $script = Script::findOrFail($scriptId); + + return [ + 'id' => $script->id, + 'title' => $script->title, + 'language' => $script->language, + 'description' => $script->description, + 'code' => $script->code, + 'run_as_user_id' => $script->run_as_user_id, + ]; + } + + /** + * @return array + */ + private function extractLinkedAssets(Process $process): array + { + $definitions = $process->getDefinitions(true); + $xpath = MigrationBpmn::createXPath($definitions); + $pmNs = WorkflowServiceProvider::PROCESS_MAKER_NS; + + $tasks = []; + foreach ($xpath->query('//bpmn:task[@id] | //bpmn:userTask[@id]') as $node) { + if (!($node instanceof \DOMElement)) { + continue; + } + + $screenRef = $node->getAttributeNS($pmNs, 'screenRef'); + $assignment = $node->getAttributeNS($pmNs, 'assignment'); + + $tasks[] = [ + 'element_id' => $node->getAttribute('id'), + 'name' => $node->getAttribute('name'), + 'screen_id' => $screenRef !== '' ? $screenRef : null, + 'assignment' => $assignment !== '' ? $assignment : null, + ]; + } + + $scriptTasks = []; + foreach ($xpath->query('//bpmn:scriptTask[@id]') as $node) { + if (!($node instanceof \DOMElement)) { + continue; + } + + $scriptTasks[] = [ + 'element_id' => $node->getAttribute('id'), + 'name' => $node->getAttribute('name'), + 'script_id' => $node->getAttributeNS($pmNs, 'scriptRef') ?: null, + ]; + } + + $gateways = []; + foreach ($xpath->query('//bpmn:exclusiveGateway[@id] | //bpmn:parallelGateway[@id] | //bpmn:inclusiveGateway[@id]') as $node) { + if ($node instanceof \DOMElement) { + $gateways[] = [ + 'element_id' => $node->getAttribute('id'), + 'name' => $node->getAttribute('name'), + 'type' => $node->localName, + ]; + } + } + + $screens = []; + $scripts = []; + foreach ($tasks as $task) { + if ($task['screen_id'] !== null && !isset($screens[$task['screen_id']])) { + $screens[$task['screen_id']] = [ + 'screen_id' => $task['screen_id'], + 'linked_to_elements' => [], + ]; + } + if ($task['screen_id'] !== null) { + $screens[$task['screen_id']]['linked_to_elements'][] = $task['element_id']; + } + } + foreach ($scriptTasks as $scriptTask) { + if ($scriptTask['script_id'] !== null && !isset($scripts[$scriptTask['script_id']])) { + $scripts[$scriptTask['script_id']] = [ + 'script_id' => $scriptTask['script_id'], + 'linked_to_elements' => [], + ]; + } + if ($scriptTask['script_id'] !== null) { + $scripts[$scriptTask['script_id']]['linked_to_elements'][] = $scriptTask['element_id']; + } + } + + $startEvents = []; + foreach ($xpath->query('//bpmn:startEvent[@id]') as $node) { + if ($node instanceof \DOMElement) { + $startEvents[] = ['element_id' => $node->getAttribute('id'), 'name' => $node->getAttribute('name')]; + } + } + + $endEvents = []; + foreach ($xpath->query('//bpmn:endEvent[@id]') as $node) { + if ($node instanceof \DOMElement) { + $endEvents[] = ['element_id' => $node->getAttribute('id'), 'name' => $node->getAttribute('name')]; + } + } + + $orphanTasks = array_values(array_filter( + $tasks, + fn (array $task): bool => $task['screen_id'] === null + )); + + return [ + 'tasks' => $tasks, + 'script_tasks' => $scriptTasks, + 'gateways' => $gateways, + 'screens' => array_values($screens), + 'scripts' => array_values($scripts), + 'start_events' => $startEvents, + 'end_events' => $endEvents, + 'tasks_without_screen' => $orphanTasks, + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/ScreenBuilder.php b/ProcessMaker/Mcp/Process/ScreenBuilder.php new file mode 100644 index 0000000000..852eab6a3f --- /dev/null +++ b/ProcessMaker/Mcp/Process/ScreenBuilder.php @@ -0,0 +1,263 @@ +> $fields + * @return array> + */ + public function buildFormConfig(array $fields, ?string $pageTitle = null, bool $includeSubmit = true): array + { + $items = []; + + foreach ($fields as $field) { + if (!is_array($field)) { + continue; + } + + $built = $this->buildField($field); + if ($built !== null) { + $items[] = $built; + } + } + + if ($includeSubmit) { + $items[] = $this->buildSubmitButton(); + } + + return [[ + 'name' => $pageTitle !== null && $pageTitle !== '' ? $pageTitle : 'Form', + 'computed' => [], + 'items' => $items, + ]]; + } + + /** + * @param array> $config + * @return array{valid: bool, issues: array>} + */ + public function validateScreenConfig(array $config): array + { + $issues = []; + $pages = $config; + + if ($pages === []) { + return [ + 'valid' => false, + 'issues' => [[ + 'code' => 'empty_config', + 'severity' => 'error', + 'message' => 'Screen config is empty.', + ]], + ]; + } + + $missingTemplates = ScreenControlCatalog::missingComponents(); + foreach ($missingTemplates as $component) { + $issues[] = [ + 'code' => 'missing_control_template', + 'severity' => 'error', + 'message' => 'MCP has no PM4 control template for ' . $component . '. Run get_mcp_capabilities after PM4 upgrade.', + ]; + } + + $hasSubmit = false; + $inputCount = 0; + + foreach ($pages as $pageIndex => $page) { + if (!is_array($page)) { + continue; + } + + foreach ($page['items'] ?? [] as $itemIndex => $item) { + if (!is_array($item)) { + continue; + } + + $component = (string) ($item['component'] ?? ''); + $itemConfig = is_array($item['config'] ?? null) ? $item['config'] : []; + + if ($component === 'FormButton' && ($itemConfig['event'] ?? null) === 'submit') { + $hasSubmit = true; + } + + if (in_array($component, ['FormInput', 'FormTextArea', 'FormSelect', 'FormSelectList', 'FormCheckbox', 'FormDatePicker'], true)) { + $inputCount++; + $name = (string) ($itemConfig['name'] ?? ''); + + if ($name === '') { + $issues[] = [ + 'code' => 'field_missing_name', + 'severity' => 'error', + 'message' => 'Input field is missing config.name.', + 'path' => "config.{$pageIndex}.items.{$itemIndex}", + ]; + } + + if (!is_array($item['inspector'] ?? null) || $item['inspector'] === []) { + $issues[] = [ + 'code' => 'field_missing_inspector', + 'severity' => 'error', + 'message' => 'Field "' . ($name ?: $component) . '" is missing inspector metadata.', + 'path' => "config.{$pageIndex}.items.{$itemIndex}", + ]; + } + + if ($component === 'FormSelect' && empty($itemConfig['options'])) { + $issues[] = [ + 'code' => 'select_missing_options', + 'severity' => 'error', + 'message' => 'Select field "' . ($name ?: 'unknown') . '" has no options.', + 'path' => "config.{$pageIndex}.items.{$itemIndex}", + ]; + } + } + } + } + + if ($inputCount > 0 && !$hasSubmit) { + $issues[] = [ + 'code' => 'missing_submit_button', + 'severity' => 'error', + 'message' => 'Task form has input fields but no submit button.', + ]; + } + + $hasErrors = count(array_filter($issues, fn (array $i): bool => ($i['severity'] ?? '') === 'error')) > 0; + + return [ + 'valid' => !$hasErrors, + 'issues' => $issues, + ]; + } + + /** + * @param array $field + * @return array|null + */ + private function buildField(array $field): ?array + { + $variable = (string) ($field['variable'] ?? $field['name'] ?? ''); + if ($variable === '') { + return null; + } + + $type = strtolower((string) ($field['type'] ?? 'text')); + $label = (string) ($field['label'] ?? $variable); + $selector = $this->slug($variable); + $placeholder = isset($field['placeholder']) ? (string) $field['placeholder'] : ''; + $validation = ($field['required'] ?? false) === true ? 'required' : ''; + + if (in_array($type, ['select', 'dropdown'], true)) { + return $this->buildSelectField($field, $selector, $label, $validation, $placeholder); + } + + $mapping = match ($type) { + 'textarea', 'multiline' => ['component' => 'FormTextArea', 'dataFormat' => 'string', 'inputType' => 'text'], + 'checkbox' => ['component' => 'FormCheckbox', 'dataFormat' => 'boolean', 'inputType' => 'checkbox'], + 'date', 'datetime' => ['component' => 'FormDatePicker', 'dataFormat' => 'datetime', 'inputType' => 'date'], + 'number' => ['component' => 'FormInput', 'dataFormat' => 'float', 'inputType' => 'number'], + 'email' => ['component' => 'FormInput', 'dataFormat' => 'string', 'inputType' => 'email'], + 'password' => ['component' => 'FormInput', 'dataFormat' => 'string', 'inputType' => 'password'], + default => ['component' => 'FormInput', 'dataFormat' => 'string', 'inputType' => 'text'], + }; + + $config = [ + 'name' => $selector, + 'label' => $label, + 'helper' => null, + 'dataFormat' => $mapping['dataFormat'], + 'validation' => $validation, + 'placeholder' => $placeholder, + 'type' => $mapping['inputType'], + ]; + + if (isset($field['default']) && $field['default'] !== '') { + $config['defaultValue'] = $field['default']; + } + + if ($mapping['component'] === 'FormTextArea') { + $config['rows'] = (int) ($field['rows'] ?? 2); + unset($config['type']); + } + + if ($mapping['component'] === 'FormCheckbox') { + unset($config['type'], $config['placeholder']); + } + + return ScreenControlCatalog::cloneControl($mapping['component'], $config, $label); + } + + /** + * @param array $field + * @return array + */ + private function buildSelectField( + array $field, + string $selector, + string $label, + string $validation, + string $placeholder + ): array { + $options = []; + foreach ($field['options'] ?? [] as $option) { + if (is_string($option)) { + $options[] = ['value' => $this->slug($option), 'content' => $option]; + continue; + } + + if (!is_array($option)) { + continue; + } + + $value = $option['value'] ?? $option['id'] ?? null; + if ($value === null) { + continue; + } + + $options[] = [ + 'value' => (string) $value, + 'content' => (string) ($option['label'] ?? $option['content'] ?? $value), + ]; + } + + if ($options === []) { + $options[] = ['value' => 'option_1', 'content' => 'Option 1']; + } + + return ScreenControlCatalog::cloneControl('FormSelect', [ + 'name' => $selector, + 'label' => $label, + 'helper' => null, + 'dataFormat' => 'string', + 'validation' => $validation, + 'placeholder' => $placeholder, + 'options' => $options, + ], $label); + } + + /** + * @return array + */ + private function buildSubmitButton(): array + { + return ScreenControlCatalog::cloneControl('FormButton', [ + 'event' => 'submit', + 'label' => 'Submit', + 'variant' => 'primary', + 'defaultSubmit' => true, + ], 'Submit Button'); + } + + private function slug(string $value): string + { + $value = preg_replace('/[^a-zA-Z0-9_]+/', '_', $value) ?? 'field'; + $value = trim($value, '_'); + + return $value !== '' ? strtolower($value) : 'field'; + } +} diff --git a/ProcessMaker/Mcp/Process/ScreenConfigNormalizer.php b/ProcessMaker/Mcp/Process/ScreenConfigNormalizer.php new file mode 100644 index 0000000000..9de91652b1 --- /dev/null +++ b/ProcessMaker/Mcp/Process/ScreenConfigNormalizer.php @@ -0,0 +1,145 @@ + */ + private const COMPONENT_ALIASES = [ + 'FormSelectList' => 'FormSelect', + ]; + + /** + * @param array $config + * @return array> + */ + public function normalize(array $config): array + { + $pages = []; + + foreach ($config as $page) { + if (!is_array($page)) { + continue; + } + + $items = []; + foreach ($page['items'] ?? [] as $item) { + if (!is_array($item)) { + continue; + } + + $items[] = $this->normalizeItem($item); + } + + if ($this->hasInputFields($items) && !$this->hasSubmitButton($items)) { + $items[] = $this->buildSubmitButton(); + } + + $pages[] = [ + 'name' => (string) ($page['name'] ?? 'Form'), + 'computed' => is_array($page['computed'] ?? null) ? $page['computed'] : [], + 'items' => $items, + ]; + } + + return $pages; + } + + /** + * @param array $item + * @return array + */ + public function normalizeItem(array $item): array + { + if ($this->isEditorReady($item)) { + return $item; + } + + $component = $this->resolveComponent($item); + $config = is_array($item['config'] ?? null) ? $item['config'] : []; + $label = (string) ($item['label'] ?? $config['label'] ?? $config['name'] ?? 'Field'); + + if (!isset($config['name']) && isset($item['name']) && is_string($item['name'])) { + $config['name'] = $item['name']; + } + + if ($component === 'FormSelect' && empty($config['options'])) { + $config['options'] = [['value' => 'option_1', 'content' => 'Option 1']]; + } + + return ScreenControlCatalog::cloneControl($component, $config, $label); + } + + /** + * @param array $item + */ + private function isEditorReady(array $item): bool + { + $inspector = $item['inspector'] ?? null; + + return is_string($item['uuid'] ?? null) + && ($item['uuid'] ?? '') !== '' + && is_array($inspector) + && $inspector !== [] + && is_string($item['editor-component'] ?? null) + && ($item['editor-component'] ?? '') !== ''; + } + + /** + * @param array $item + */ + private function resolveComponent(array $item): string + { + $component = (string) ($item['component'] ?? 'FormInput'); + + return self::COMPONENT_ALIASES[$component] ?? $component; + } + + /** + * @param array> $items + */ + private function hasInputFields(array $items): bool + { + foreach ($items as $item) { + $component = (string) ($item['component'] ?? ''); + if (in_array($component, ['FormInput', 'FormTextArea', 'FormSelect', 'FormSelectList', 'FormCheckbox', 'FormDatePicker', 'FormRecordList'], true)) { + return true; + } + } + + return false; + } + + /** + * @param array> $items + */ + private function hasSubmitButton(array $items): bool + { + foreach ($items as $item) { + $config = is_array($item['config'] ?? null) ? $item['config'] : []; + if (($item['component'] ?? null) === 'FormButton' && ($config['event'] ?? null) === 'submit') { + return true; + } + } + + return false; + } + + /** + * @return array + */ + private function buildSubmitButton(): array + { + return ScreenControlCatalog::cloneControl('FormButton', [ + 'event' => 'submit', + 'label' => 'Submit', + 'variant' => 'primary', + 'defaultSubmit' => true, + ], 'Submit Button'); + } +} diff --git a/ProcessMaker/Mcp/Process/ScreenControlCatalog.php b/ProcessMaker/Mcp/Process/ScreenControlCatalog.php new file mode 100644 index 0000000000..dff838be90 --- /dev/null +++ b/ProcessMaker/Mcp/Process/ScreenControlCatalog.php @@ -0,0 +1,231 @@ +>|null */ + private static ?array $byComponent = null; + + /** @var array */ + private const REQUIRED_COMPONENTS = [ + 'FormInput', + 'FormTextArea', + 'FormSelect', + 'FormCheckbox', + 'FormDatePicker', + 'FormButton', + ]; + + /** + * @return array + */ + public static function cloneControl(string $component, array $config, string $label): array + { + self::boot(); + + $template = self::$byComponent[$component] ?? null; + if ($template === null) { + throw new RuntimeException('No PM4 screen control template for component: ' . $component); + } + + /** @var array $item */ + $item = json_decode(json_encode($template, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); + $item['uuid'] = (string) Str::uuid(); + $item['label'] = $label; + $item['component'] = $component; + $item['config'] = array_replace_recursive($item['config'] ?? [], $config); + + return $item; + } + + /** + * @param array $components + * @return array + */ + public static function missingComponents(array $components = self::REQUIRED_COMPONENTS): array + { + self::boot(); + + return array_values(array_filter( + $components, + fn (string $component): bool => !isset(self::$byComponent[$component]) + )); + } + + /** + * @return array + */ + public static function catalogReport(): array + { + self::boot(); + + $report = []; + foreach (self::$byComponent as $component => $item) { + $report[$component] = [ + 'source' => (string) ($item['_mcp_source'] ?? 'unknown'), + 'inspector_fields' => is_array($item['inspector'] ?? null) ? count($item['inspector']) : 0, + ]; + } + + ksort($report); + + return $report; + } + + public static function inspectorFor(string $component): array + { + self::boot(); + + $inspector = self::$byComponent[$component]['inspector'] ?? null; + + return is_array($inspector) ? $inspector : []; + } + + public static function editorComponentFor(string $component): string + { + self::boot(); + + $value = self::$byComponent[$component]['editor-component'] ?? null; + + return is_string($value) && $value !== '' ? $value : $component; + } + + public static function editorControlFor(string $component): string + { + self::boot(); + + $value = self::$byComponent[$component]['editor-control'] ?? null; + + return is_string($value) && $value !== '' ? $value : self::editorComponentFor($component); + } + + private static function boot(): void + { + if (self::$byComponent !== null) { + return; + } + + self::$byComponent = []; + + foreach (glob(database_path('processes/screens/*.json')) ?: [] as $path) { + self::indexFile($path, 'core:' . basename($path)); + } + + self::indexFallbackFile(__DIR__ . '/screen-control-fallbacks.json', 'mcp:fallbacks'); + } + + private static function indexFile(string $path, string $source): void + { + if (!is_readable($path)) { + return; + } + + $pages = json_decode((string) file_get_contents($path), true); + if (!is_array($pages)) { + return; + } + + // screen_package format + if (isset($pages['screens']) && is_array($pages['screens'])) { + foreach ($pages['screens'] as $screen) { + if (!is_array($screen['config'] ?? null)) { + continue; + } + foreach ($screen['config'] as $page) { + self::indexPage($page, $source); + } + } + + return; + } + + foreach ($pages as $page) { + self::indexPage($page, $source); + } + } + + /** + * @param array $page + */ + private static function indexPage(array $page, string $source): void + { + foreach ($page['items'] ?? [] as $item) { + if (!is_array($item)) { + continue; + } + + $component = $item['component'] ?? null; + if (!is_string($component) || $component === '') { + continue; + } + + self::registerTemplate($component, $item, $source); + } + } + + private static function indexFallbackFile(string $path, string $source): void + { + if (!is_readable($path)) { + return; + } + + $data = json_decode((string) file_get_contents($path), true); + if (!is_array($data['controls'] ?? null)) { + return; + } + + foreach ($data['controls'] as $component => $item) { + if (!is_string($component) || !is_array($item)) { + continue; + } + + self::registerTemplate($component, $item, $source); + } + } + + /** + * @param array $item + */ + private static function registerTemplate(string $component, array $item, string $source): void + { + $item['_mcp_source'] = $source; + $existing = self::$byComponent[$component] ?? null; + + if ($existing === null || self::templateScore($item) > self::templateScore($existing)) { + self::$byComponent[$component] = $item; + } + } + + /** + * @param array $item + */ + private static function templateScore(array $item): int + { + $score = is_array($item['inspector'] ?? null) ? count($item['inspector']) * 10 : 0; + + if (isset($item['editor-control'])) { + $score += 5; + } + if (isset($item['editor-component'])) { + $score += 5; + } + if (isset($item['config']['dataFormat'])) { + $score += 3; + } + if (($item['config']['defaultSubmit'] ?? false) === true) { + $score += 3; + } + + return $score; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/AddBpmnExclusiveGatewayTool.php b/ProcessMaker/Mcp/Process/Tools/AddBpmnExclusiveGatewayTool.php new file mode 100644 index 0000000000..70e892f822 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/AddBpmnExclusiveGatewayTool.php @@ -0,0 +1,47 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'name' => 'required|string', + 'element_id' => 'sometimes|string', + ]); + + return Response::structured($this->designer->addExclusiveGateway( + $data['process_id'], + $data['name'], + $data['element_id'] ?? null, + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->required(), + 'name' => $schema->string()->description('Gateway name')->required(), + 'element_id' => $schema->string()->description('Optional element ID'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/AddBpmnSequenceFlowTool.php b/ProcessMaker/Mcp/Process/Tools/AddBpmnSequenceFlowTool.php new file mode 100644 index 0000000000..14aedd6744 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/AddBpmnSequenceFlowTool.php @@ -0,0 +1,53 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'source_ref' => 'required|string', + 'target_ref' => 'required|string', + 'flow_id' => 'sometimes|string', + 'condition' => 'sometimes|string', + ]); + + return Response::structured($this->designer->addSequenceFlow( + $data['process_id'], + $data['source_ref'], + $data['target_ref'], + $data['flow_id'] ?? null, + $data['condition'] ?? null, + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->required(), + 'source_ref' => $schema->string()->description('Source BPMN element ID')->required(), + 'target_ref' => $schema->string()->description('Target BPMN element ID')->required(), + 'flow_id' => $schema->string()->description('Optional flow ID'), + 'condition' => $schema->string()->description('Optional condition label/expression'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/AddBpmnUserTaskTool.php b/ProcessMaker/Mcp/Process/Tools/AddBpmnUserTaskTool.php new file mode 100644 index 0000000000..19f485a3b4 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/AddBpmnUserTaskTool.php @@ -0,0 +1,50 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'name' => 'required|string', + 'element_id' => 'sometimes|string', + 'insert_after_element_id' => 'sometimes|string', + ]); + + return Response::structured($this->designer->addUserTask( + $data['process_id'], + $data['name'], + $data['element_id'] ?? null, + $data['insert_after_element_id'] ?? null, + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + 'name' => $schema->string()->description('Task name')->required(), + 'element_id' => $schema->string()->description('Optional BPMN element ID'), + 'insert_after_element_id' => $schema->string()->description('Insert after this element outgoing flow'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/AnalyzeProcessTool.php b/ProcessMaker/Mcp/Process/Tools/AnalyzeProcessTool.php new file mode 100644 index 0000000000..5c39101ccd --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/AnalyzeProcessTool.php @@ -0,0 +1,39 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + ]); + + return Response::structured($this->analyzer->analyze($data['process_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/ApplyProcessDesignTool.php b/ProcessMaker/Mcp/Process/Tools/ApplyProcessDesignTool.php new file mode 100644 index 0000000000..7d6d1bf380 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/ApplyProcessDesignTool.php @@ -0,0 +1,39 @@ +validate([ + 'plan' => 'required|array', + ]); + + return Response::structured($this->designer->applyDesignPlan($data['plan'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'plan' => $schema->object()->description('Design plan: process, bpmn_template, screens, scripts, variables, tasks')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/ApplyProcessVariablesTool.php b/ProcessMaker/Mcp/Process/Tools/ApplyProcessVariablesTool.php new file mode 100644 index 0000000000..d0b36e4701 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/ApplyProcessVariablesTool.php @@ -0,0 +1,44 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'variables' => 'required|array', + ]); + + return Response::structured($this->designer->applyVariables( + $data['process_id'], + $data['variables'], + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->required(), + 'variables' => $schema->array()->description('[{ var_name, var_default, var_label }]')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/CreateScreenFromFieldsTool.php b/ProcessMaker/Mcp/Process/Tools/CreateScreenFromFieldsTool.php new file mode 100644 index 0000000000..b7a02c2b1d --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/CreateScreenFromFieldsTool.php @@ -0,0 +1,65 @@ +validate([ + 'title' => 'required|string', + 'description' => 'sometimes|string', + 'fields' => 'required|array', + 'process_id' => 'sometimes|string|exists:processes,id', + 'element_id' => 'sometimes|string', + ]); + + $result = $this->writer->createScreenResult([ + 'title' => $data['title'], + 'description' => $data['description'] ?? $data['title'], + 'config_json' => $this->screenBuilder->buildFormConfig($data['fields'], $data['title']), + ]); + + $linked = null; + if (!empty($data['process_id']) && !empty($data['element_id'])) { + $this->writer->linkScreenToTask($data['process_id'], $data['element_id'], (string) $result->screen->id); + $linked = ['process_id' => $data['process_id'], 'element_id' => $data['element_id']]; + } + + return Response::structured(array_merge( + $this->writer->screenCreationPayload($result), + ['linked' => $linked], + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'title' => $schema->string()->required(), + 'description' => $schema->string(), + 'fields' => $schema->array()->description('[{ variable, label, type, required, default }]')->required(), + 'process_id' => $schema->string()->description('Optional process to link screen'), + 'element_id' => $schema->string()->description('BPMN task element ID for screenRef'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/GetMcpCapabilitiesTool.php b/ProcessMaker/Mcp/Process/Tools/GetMcpCapabilitiesTool.php new file mode 100644 index 0000000000..03ec4d79ee --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/GetMcpCapabilitiesTool.php @@ -0,0 +1,29 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + ]); + + return Response::structured($this->reader->getProcessBpmn($data['process_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/GetProcessInventoryTool.php b/ProcessMaker/Mcp/Process/Tools/GetProcessInventoryTool.php new file mode 100644 index 0000000000..c2ec47179a --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/GetProcessInventoryTool.php @@ -0,0 +1,39 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + ]); + + return Response::structured($this->reader->getProcessInventory($data['process_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/GetProcessTool.php b/ProcessMaker/Mcp/Process/Tools/GetProcessTool.php new file mode 100644 index 0000000000..937c6c6162 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/GetProcessTool.php @@ -0,0 +1,39 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + ]); + + return Response::structured($this->reader->getProcess($data['process_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/GetScreenTool.php b/ProcessMaker/Mcp/Process/Tools/GetScreenTool.php new file mode 100644 index 0000000000..40d363e0ea --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/GetScreenTool.php @@ -0,0 +1,39 @@ +validate([ + 'screen_id' => 'required|string|exists:screens,id', + ]); + + return Response::structured($this->reader->getScreen($data['screen_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'screen_id' => $schema->string()->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/GetScriptTool.php b/ProcessMaker/Mcp/Process/Tools/GetScriptTool.php new file mode 100644 index 0000000000..59df5c6fa8 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/GetScriptTool.php @@ -0,0 +1,39 @@ +validate([ + 'script_id' => 'required|string|exists:scripts,id', + ]); + + return Response::structured($this->reader->getScript($data['script_id'])); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'script_id' => $schema->string()->required(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/ListProcessesTool.php b/ProcessMaker/Mcp/Process/Tools/ListProcessesTool.php new file mode 100644 index 0000000000..9289b31daf --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/ListProcessesTool.php @@ -0,0 +1,50 @@ +validate([ + 'start' => 'sometimes|integer|min:0', + 'limit' => 'sometimes|integer|min:1|max:100', + 'filter_name' => 'sometimes|string', + 'status' => 'sometimes|string|in:ACTIVE,INACTIVE,ARCHIVED,all', + ]); + + return Response::structured($this->reader->listProcesses( + (int) ($data['start'] ?? 0), + (int) ($data['limit'] ?? 25), + $data['filter_name'] ?? null, + $data['status'] ?? 'ACTIVE', + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'start' => $schema->integer()->description('Pagination offset'), + 'limit' => $schema->integer()->description('Max results (default 25)'), + 'filter_name' => $schema->string()->description('Filter by process name'), + 'status' => $schema->string()->description('ACTIVE, INACTIVE, ARCHIVED, or all'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/ListScreensTool.php b/ProcessMaker/Mcp/Process/Tools/ListScreensTool.php new file mode 100644 index 0000000000..2ed1999028 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/ListScreensTool.php @@ -0,0 +1,47 @@ +validate([ + 'start' => 'sometimes|integer|min:0', + 'limit' => 'sometimes|integer|min:1|max:100', + 'filter' => 'sometimes|string', + ]); + + return Response::structured($this->reader->listScreens( + (int) ($data['start'] ?? 0), + (int) ($data['limit'] ?? 25), + $data['filter'] ?? null, + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'start' => $schema->integer(), + 'limit' => $schema->integer(), + 'filter' => $schema->string(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/ListScriptsTool.php b/ProcessMaker/Mcp/Process/Tools/ListScriptsTool.php new file mode 100644 index 0000000000..e16a2b1607 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/ListScriptsTool.php @@ -0,0 +1,47 @@ +validate([ + 'start' => 'sometimes|integer|min:0', + 'limit' => 'sometimes|integer|min:1|max:100', + 'filter' => 'sometimes|string', + ]); + + return Response::structured($this->reader->listScripts( + (int) ($data['start'] ?? 0), + (int) ($data['limit'] ?? 25), + $data['filter'] ?? null, + )); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'start' => $schema->integer(), + 'limit' => $schema->integer(), + 'filter' => $schema->string(), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/Tools/UpdateProcessTool.php b/ProcessMaker/Mcp/Process/Tools/UpdateProcessTool.php new file mode 100644 index 0000000000..b0d1add3d1 --- /dev/null +++ b/ProcessMaker/Mcp/Process/Tools/UpdateProcessTool.php @@ -0,0 +1,56 @@ +validate([ + 'process_id' => 'required|string|exists:processes,id', + 'name' => 'sometimes|string', + 'description' => 'sometimes|string', + 'status' => 'sometimes|string|in:ACTIVE,INACTIVE,ARCHIVED', + 'process_category_id' => 'sometimes|integer|exists:process_categories,id', + ]); + + $processId = $data['process_id']; + unset($data['process_id']); + + $process = $this->writer->updateProcess($processId, $data); + + return Response::structured([ + 'id' => $process->id, + 'name' => $process->name, + 'status' => $process->status, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'process_id' => $schema->string()->description('PM4 process ID')->required(), + 'name' => $schema->string()->description('Process name'), + 'description' => $schema->string()->description('Process description'), + 'status' => $schema->string()->description('ACTIVE, INACTIVE, or ARCHIVED'), + 'process_category_id' => $schema->integer()->description('Process category ID'), + ]; + } +} diff --git a/ProcessMaker/Mcp/Process/screen-control-fallbacks.json b/ProcessMaker/Mcp/Process/screen-control-fallbacks.json new file mode 100644 index 0000000000..2ab52e3120 --- /dev/null +++ b/ProcessMaker/Mcp/Process/screen-control-fallbacks.json @@ -0,0 +1,749 @@ +{ + "controls": { + "FormInput": { + "label": "Line Input", + "config": { + "icon": "far fa-square", + "name": "form_input_1", + "type": "text", + "label": "New Input", + "helper": null, + "dataFormat": "string", + "validation": null, + "placeholder": null + }, + "component": "FormInput", + "inspector": [ + { + "type": "FormInput", + "field": "name", + "config": { + "name": "Variable Name", + "label": "Variable Name", + "helper": "A variable name is a symbolic name to reference information.", + "validation": "regex:/^([a-zA-Z]([a-zA-Z0-9_]?)+\\.?)+(? Date ##/##/####
SSN ###-##-####
Phone (###) ###-####", + "validation": null + } + }, + { + "type": "FormInput", + "field": "customCssSelector", + "config": { + "label": "CSS Selector Name", + "helper": "Use this in your custom css rules", + "validation": "regex: [-?[_a-zA-Z]+[_-a-zA-Z0-9]*]" + } + }, + { + "type": "FormInput", + "field": "ariaLabel", + "config": { + "label": "Aria Label", + "helper": "Attribute designed to help assistive technology (e.g. screen readers) attach a label" + } + }, + { + "type": "FormInput", + "field": "tabindex", + "config": { + "label": "Tab Order", + "helper": "Order in which a user will move focus from one control to another by pressing the Tab key", + "validation": "regex: [0-9]*" + } + } + ], + "editor-control": "FormInput", + "editor-component": "FormInput" + }, + "FormButton": { + "label": "Submit Button", + "config": { + "icon": "fas fa-share-square", + "name": null, + "event": "submit", + "label": "New Submit", + "tooltip": [], + "variant": "primary", + "fieldValue": null, + "defaultSubmit": true + }, + "component": "FormButton", + "inspector": [ + { + "type": "FormInput", + "field": "label", + "config": { + "label": "Label", + "helper": "The label describes the button's text" + } + }, + { + "type": "FormInput", + "field": "name", + "config": { + "name": "Variable Name", + "label": "Variable Name", + "helper": "A variable name is a symbolic name to reference information.", + "validation": "regex:/^(?:[A-Za-z])(?:[0-9A-Z_.a-z])*(? Date ##/##/####
SSN ###-##-####
Phone (###) ###-####", + "validation": null + } + }, + { + "type": "FormInput", + "field": "customCssSelector", + "config": { + "label": "CSS Selector Name", + "helper": "Use this in your custom css rules", + "validation": "regex: [-?[_a-zA-Z]+[_-a-zA-Z0-9]*]" + } + }, + { + "type": "FormInput", + "field": "ariaLabel", + "config": { + "label": "Aria Label", + "helper": "Attribute designed to help assistive technology (e.g. screen readers) attach a label" + } + }, + { + "type": "FormInput", + "field": "tabindex", + "config": { + "label": "Tab Order", + "helper": "Order in which a user will move focus from one control to another by pressing the Tab key", + "validation": "regex: [0-9]*" + } + } + ], + "editor-control": "FormSubmit", + "editor-component": "FormButton" + }, + "FormDatePicker": { + "config": { + "label": "Start date", + "name": "startDate", + "placeholder": "", + "validation": "", + "helper": null, + "type": "date" + }, + "inspector": [ + { + "type": "FormInput", + "field": "name", + "config": { + "label": "Field Name", + "name": "Field Name", + "validation": "required", + "helper": "The data name for this field" + } + }, + { + "type": "FormSelect", + "field": "type", + "config": { + "label": "Field Type", + "name": "Field Type", + "helper": "The type for this field", + "options": [ + { + "value": "text", + "content": "Text" + }, + { + "value": "password", + "content": "Password" + } + ] + } + }, + { + "type": "FormInput", + "field": "label", + "config": { + "label": "Field Label", + "helper": "The label describes the fields name" + } + }, + { + "type": "FormInput", + "field": "validation", + "config": { + "label": "Validation", + "helper": "The validation rules needed for this field" + } + }, + { + "type": "FormInput", + "field": "placeholder", + "config": { + "label": "Placeholder", + "helper": "The placeholder is what is shown in the field when no value is provided yet" + } + }, + { + "type": "FormInput", + "field": "helper", + "config": { + "label": "Help Text", + "helper": "Help text is meant to provide additional guidance on the field's value" + } + } + ], + "component": "FormDatePicker", + "editor-component": "FormDatePicker", + "label": "Line Input" + }, + "FormTextArea": { + "config": { + "label": "Reason", + "placeholder": "", + "helper": null, + "rows": 2, + "name": "reason" + }, + "inspector": [ + { + "type": "FormInput", + "field": "name", + "config": { + "label": "Field Name", + "name": "Field Name", + "validation": "required", + "helper": "The data name for this field" + } + }, + { + "type": "FormInput", + "field": "label", + "config": { + "label": "Field Label", + "helper": "The label describes the fields name" + } + }, + { + "type": "FormInput", + "field": "validation", + "config": { + "label": "Validation", + "helper": "The validation rules needed for this field" + } + }, + { + "type": "FormInput", + "field": "rows", + "config": { + "label": "Rows", + "helper": "The number of rows to provide for input" + } + }, + { + "type": "FormInput", + "field": "placeholder", + "config": { + "label": "Placeholder", + "helper": "The placeholder is what is shown in the field when no value is provided yet" + } + }, + { + "type": "FormInput", + "field": "helper", + "config": { + "label": "Help Text", + "helper": "Help text is meant to provide additional guidance on the field's value" + } + } + ], + "component": "FormTextArea", + "editor-component": "FormTextArea", + "label": "Textarea" + }, + "FormCheckbox": { + "config": { + "label": "Yes", + "helper": null, + "name": "approved", + "checked": false, + "validation": "" + }, + "inspector": [ + { + "type": "FormInput", + "field": "name", + "config": { + "label": "Field Name", + "helper": "The name of the group for the checkbox. All checkboxes which share the same name will work together." + } + }, + { + "type": "FormInput", + "field": "label", + "config": { + "label": "Field Label", + "helper": "The label describes the fields name" + } + }, + { + "type": "FormInput", + "field": "helper", + "config": { + "label": "Help Text", + "helper": "Help text is meant to provide additional guidance on the field's value" + } + }, + { + "type": "FormCheckbox", + "field": "checked", + "config": { + "label": "Initially Checked?", + "helper": "Should the checkbox be checked by default" + } + } + ], + "component": "FormCheckbox", + "editor-component": "FormCheckbox", + "label": "Checkbox" + }, + "FormSelect": { + "label": "Select", + "config": { + "name": "item", + "label": "Item", + "helper": null, + "options": [ + { + "value": null, + "content": null + }, + { + "value": "pens", + "content": "Pens" + }, + { + "value": "notebooks", + "content": "Notebooks" + }, + { + "value": "markers", + "content": "Markers" + }, + { + "value": "laptop", + "content": "Laptop" + }, + { + "value": "ip_phone", + "content": "IP Phone" + } + ], + "validation": null, + "placeholder": null + }, + "component": "FormSelect", + "inspector": [ + { + "type": "FormInput", + "field": "name", + "config": { + "label": "Field Name", + "helper": "The data name for this field" + } + }, + { + "type": "FormInput", + "field": "label", + "config": { + "label": "Field Label", + "helper": "The label describes the fields name" + } + }, + { + "type": "FormInput", + "field": "validation", + "config": { + "label": "Validation", + "helper": "The validation rules needed for this field" + } + }, + { + "type": "FormInput", + "field": "helper", + "config": { + "label": "Help Text", + "helper": "Help text is meant to provide additional guidance on the field's value" + } + }, + { + "type": "OptionsList", + "field": "options", + "config": { + "label": "Options List", + "helper": "List of options available in the select drop down" + } + } + ], + "editor-component": "FormSelect" + } + } +} \ No newline at end of file diff --git a/ProcessMaker/Providers/RouteServiceProvider.php b/ProcessMaker/Providers/RouteServiceProvider.php index e12823a721..1bc5f8a12c 100644 --- a/ProcessMaker/Providers/RouteServiceProvider.php +++ b/ProcessMaker/Providers/RouteServiceProvider.php @@ -63,6 +63,8 @@ public function map() $this->mapWebRoutes(); $this->mapEngineRoutes(); + + $this->mapAiRoutes(); } /** @@ -105,4 +107,11 @@ protected function mapEngineRoutes() Route::middleware('engine') ->group(base_path('routes/engine.php')); } + + protected function mapAiRoutes() + { + if (file_exists(base_path('routes/ai.php'))) { + require base_path('routes/ai.php'); + } + } } diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 0000000000..233792da36 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,20 @@ +writerInstance ??= app(Writer::class); + } + + protected function tearDown(): void + { + if (Storage::disk('local')->exists('migration-logs')) { + foreach (Storage::disk('local')->files('migration-logs') as $file) { + Storage::disk('local')->delete($file); + } + } + + parent::tearDown(); + } + + public function testCreateProcess(): void + { + $this->actingAs($this->user); + + $process = $this->writer()->createProcess([ + 'name' => 'MCP Migration Process', + 'description' => 'Created via migration writer', + ]); + + $this->assertSame('MCP Migration Process', $process->name); + $this->assertSame($this->user->id, $process->user_id); + $this->assertNotEmpty($process->bpmn); + } + + public function testCreateScreen(): void + { + $screen = $this->writer()->createScreen([ + 'title' => 'MCP Migration Screen', + 'config_json' => [[ + 'name' => 'Form', + 'computed' => [], + 'items' => [], + ]], + ]); + + $this->assertSame('MCP Migration Screen', $screen->title); + $this->assertCount(1, $screen->config); + $this->assertSame('Form', $screen->config[0]['name']); + $this->assertSame([], $screen->config[0]['items']); + $this->assertSame([], $screen->config[0]['computed']); + } + + public function testCreateScript(): void + { + $this->actingAs($this->user); + + $script = $this->writer()->createScript([ + 'title' => 'MCP Migration Script', + 'code' => 'assertSame('MCP Migration Script', $script->title); + $this->assertSame($this->user->id, $script->run_as_user_id); + } + + public function testValidateProcess(): void + { + $process = Process::factory()->create(['user_id' => $this->user->id]); + + $result = $this->writer()->validateProcess($process->id); + + $this->assertSame($process->id, $result['process_id']); + $this->assertArrayHasKey('valid', $result); + $this->assertArrayHasKey('errors', $result); + } + + public function testApplyMigrationBundleCreatesProcessAndScripts(): void + { + $this->actingAs($this->user); + + $result = $this->writer()->applyMigrationBundle([ + 'source' => [ + 'pro_uid' => 'abc-123', + 'title' => 'Invoice Approval', + ], + 'triggers' => [ + [ + 'TRI_UID' => 'tri-1', + 'TRI_TITLE' => 'Set Approved', + 'TRI_WEBBOT' => 'assertNotEmpty($result['process_id']); + $this->assertCount(1, $result['scripts']); + $this->assertSame('Set Approved', $result['scripts'][0]['title']); + $this->assertArrayHasKey('gap_report', $result); + $this->assertArrayHasKey('validation', $result); + $this->assertArrayHasKey('migration_log', $result); + $this->assertSame('completed', $result['migration_log']['status']); + } + + public function testApplyMigrationBundleStoresVariablesAndMetadata(): void + { + $this->actingAs($this->user); + + $result = $this->writer()->applyMigrationBundle([ + 'source' => ['pro_uid' => 'abc-123', 'title' => 'Variables Process'], + 'variables' => [[ + 'var_name' => 'invoice_total', + 'var_default' => '0', + ]], + 'document_steps' => [[ + 'tas_uid' => 'task-1', + 'step_type' => 'INPUT_DOCUMENT', + 'doc_uid' => 'doc-1', + ]], + ]); + + $process = Process::findOrFail($result['process_id']); + $this->assertSame('invoice_total', $process->properties['variables'][0]['name']); + $this->assertSame('0', $process->properties['request_data_defaults']['invoice_total']); + $this->assertContains('document_steps', $result['applied_documents']); + } + + public function testApplyMigrationBundleCreatesScreensFromDynaforms(): void + { + $this->actingAs($this->user); + + $result = $this->writer()->applyMigrationBundle([ + 'source' => [ + 'pro_uid' => 'abc-123', + 'title' => 'Invoice Approval', + ], + 'dynaforms' => [ + [ + 'DYN_UID' => 'dyn-1', + 'DYN_TITLE' => 'Invoice Form', + 'DYN_CONTENT' => json_encode([ + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [[ + 'type' => 'text', + 'variable' => 'invoice_number', + 'label' => 'Invoice Number', + ]], + ], + ], + ], + ]), + ], + ], + ]); + + $this->assertCount(1, $result['screens']); + $this->assertSame('dyn-1', $result['screens'][0]['pm3_uid']); + $this->assertSame('Invoice Form', $result['screens'][0]['title']); + } + + public function testCreateScreenFromDynaform(): void + { + $result = $this->writer()->createScreenFromDynaform([ + 'DYN_TITLE' => 'Customer Form', + 'DYN_CONTENT' => json_encode([ + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [[ + 'type' => 'text', + 'variable' => 'customer_name', + 'label' => 'Customer Name', + ]], + ], + ], + ], + ]), + ]); + + $this->assertSame('Customer Form', $result['screen']->title); + $this->assertSame('FormInput', $result['screen']->config[0]['items'][0]['component']); + } + + public function testLinkScriptToTask(): void + { + $this->actingAs($this->user); + + $bpmn = file_get_contents(base_path('tests/Feature/Api/processes/inline-task-execution/InlineSequentialScriptServiceTasks.bpmn')); + $process = Process::factory()->create([ + 'user_id' => $this->user->id, + 'bpmn' => $bpmn, + ]); + $script = Script::factory()->create(['run_as_user_id' => $this->user->id]); + + $updated = $this->writer()->linkScriptToTask($process->id, 'script_a', $script->id); + $definitions = $updated->getDefinitions(true); + $xpath = new \DOMXPath($definitions); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + $node = $xpath->query("//*[@id='script_a']")->item(0); + + $this->assertNotNull($node); + $this->assertSame( + (string) $script->id, + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'scriptRef') + ); + } + + public function testAutoLinkMigrationAssetsLinksScreenByTaskId(): void + { + $this->actingAs($this->user); + + $bpmn = str_replace( + 'id="node_2"', + 'id="task-1"', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + $bpmn = preg_replace('/pm:screenRef="[^"]*"/', '', $bpmn); + + $process = Process::factory()->create([ + 'user_id' => $this->user->id, + 'bpmn' => $bpmn, + ]); + $screen = $this->writer()->createScreen([ + 'title' => 'Linked Screen', + 'config_json' => ['items' => []], + ]); + + $result = $this->writer()->autoLinkMigrationAssets($process->id, [ + 'screens' => [[ + 'pm3_uid' => 'dyn-1', + 'pm4_id' => $screen->id, + 'title' => 'Linked Screen', + ]], + 'steps' => [[ + 'step_type' => 'DYNAFORM', + 'tas_uid' => 'task-1', + 'dyn_uid' => 'dyn-1', + ]], + ]); + + $this->assertCount(1, $result['linked_screens']); + $this->assertSame('task-1', $result['linked_screens'][0]['element_id']); + + $updated = Process::findOrFail($process->id); + $xpath = new \DOMXPath($updated->getDefinitions(true)); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + $node = $xpath->query("//*[@id='task-1']")->item(0); + + $this->assertSame( + (string) $screen->id, + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'screenRef') + ); + } + + public function testAutoLinkMigrationAssetsLinksScreenByTaskTitle(): void + { + $this->actingAs($this->user); + + $bpmn = preg_replace( + '/pm:screenRef="[^"]*"/', + '', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + + $process = Process::factory()->create([ + 'user_id' => $this->user->id, + 'bpmn' => $bpmn, + ]); + $screen = $this->writer()->createScreen([ + 'title' => 'Linked Screen', + 'config_json' => ['items' => []], + ]); + + $result = $this->writer()->autoLinkMigrationAssets($process->id, [ + 'screens' => [[ + 'pm3_uid' => 'dyn-1', + 'pm4_id' => $screen->id, + 'title' => 'Linked Screen', + ]], + 'tasks' => [[ + 'tas_uid' => 'pm3-task-uid', + 'tas_title' => 'New Task', + ]], + 'steps' => [[ + 'step_type' => 'DYNAFORM', + 'tas_uid' => 'pm3-task-uid', + 'dyn_uid' => 'dyn-1', + ]], + ]); + + $this->assertCount(1, $result['linked_screens']); + $this->assertSame('node_2', $result['linked_screens'][0]['element_id']); + } + + public function testCreateProcessUsesFirstUserWhenNotAuthenticated(): void + { + auth()->logout(); + + $process = $this->writer()->createProcess([ + 'name' => 'Unauthenticated Process', + ]); + + $this->assertSame(User::orderBy('id')->value('id'), $process->user_id); + } + + public function testApplyTaskAssignmentsSetsPm4AssignmentAttributes(): void + { + $this->actingAs($this->user); + + $bpmn = preg_replace( + '/pm:screenRef="[^"]*"/', + '', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + + $process = Process::factory()->create([ + 'user_id' => $this->user->id, + 'bpmn' => $bpmn, + ]); + + $result = $this->writer()->applyTaskAssignments($process->id, [ + 'task_assignments' => [[ + 'tas_uid' => 'pm3-task-uid', + 'pm4_assignment' => 'user_group', + 'user_uids' => ['pm3-user-1'], + 'group_uids' => ['pm3-group-1'], + ]], + 'tasks' => [[ + 'tas_uid' => 'pm3-task-uid', + 'tas_title' => 'New Task', + ]], + 'identity_map' => [ + 'users' => ['pm3-user-1' => $this->user->id], + 'groups' => ['pm3-group-1' => 1], + ], + ]); + + $this->assertCount(1, $result['applied']); + $this->assertSame('node_2', $result['applied'][0]['element_id']); + + $updated = Process::findOrFail($process->id); + $xpath = new \DOMXPath($updated->getDefinitions(true)); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + $node = $xpath->query("//*[@id='node_2']")->item(0); + + $this->assertSame( + 'user_group', + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'assignment') + ); + $this->assertSame( + (string) $this->user->id, + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'assignedUsers') + ); + } + + public function testCreateCollectionFromReportTable(): void + { + if (!class_exists(\ProcessMaker\Plugins\Collections\Models\Collection::class)) { + $this->markTestSkipped('package-collections is not installed.'); + } + + $this->actingAs($this->user); + + ProcessCategory::factory()->create(['is_system' => true]); + + $result = $this->writer()->createCollectionFromReportTable([ + 'rep_tab_uid' => 'rep-1', + 'rep_tab_title' => 'Cases Report', + 'fields' => [[ + 'rep_var_field' => 'APP_NUMBER', + 'rep_var_title' => 'Case Number', + 'rep_var_type' => 'string', + ]], + ]); + + $this->assertSame('Cases Report', $result['collection']->name); + $this->assertNotEmpty($result['collection']->columns); + } + + public function testApplyMigrationBundleAppliesAssignmentsWhenBpmnProvided(): void + { + $this->actingAs($this->user); + + $bpmn = preg_replace( + '/pm:screenRef="[^"]*"/', + '', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + + $result = $this->writer()->applyMigrationBundle([ + 'source' => [ + 'pro_uid' => 'abc-123', + 'title' => 'Assigned Process', + ], + 'bpmn' => ['xml' => $bpmn], + 'tasks' => [[ + 'tas_uid' => 'pm3-task-uid', + 'tas_title' => 'New Task', + ]], + 'task_assignments' => [[ + 'tas_uid' => 'pm3-task-uid', + 'pm4_assignment' => 'previous_task_assignee', + 'user_uids' => [], + 'group_uids' => [], + ]], + ]); + + $this->assertCount(1, $result['applied_assignments']); + $this->assertSame('previous_task_assignee', $result['applied_assignments'][0]['pm4_assignment']); + } + + public function testApplyMigrationBundleAppliesManualIdentityMapForTaskAssignments(): void + { + $this->actingAs($this->user); + + $assignee = User::factory()->create([ + 'username' => 'task.assignee', + 'email' => 'task.assignee@example.com', + ]); + + $bpmn = preg_replace( + '/pm:screenRef="[^"]*"/', + '', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + + $result = $this->writer()->applyMigrationBundle([ + 'source' => [ + 'pro_uid' => 'abc-123', + 'title' => 'Identity Process', + ], + 'bpmn' => ['xml' => $bpmn], + 'tasks' => [[ + 'tas_uid' => 'pm3-task-uid', + 'tas_title' => 'New Task', + ]], + 'task_assignments' => [[ + 'tas_uid' => 'pm3-task-uid', + 'pm4_assignment' => 'user_group', + 'user_uids' => ['pm3-user-1'], + 'group_uids' => [], + ]], + 'identity_map' => [ + 'users' => ['pm3-user-1' => (string) $assignee->id], + 'groups' => [], + ], + ]); + + $this->assertCount(1, $result['applied_assignments']); + $this->assertSame('node_2', $result['applied_assignments'][0]['element_id']); + } + + public function testCreateScreenFromDynaformCreatesRecordListForGrid(): void + { + $result = $this->writer()->createScreenFromDynaform([ + 'DYN_TITLE' => 'Grid Form', + 'DYN_CONTENT' => json_encode([ + 'css' => '#items_grid { color: red; }', + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [[ + 'type' => 'grid', + 'variable' => 'items_grid', + 'label' => 'Items', + 'columns' => [ + [ + 'type' => 'text', + 'variable' => 'item_name', + 'label' => 'Item Name', + ], + ], + ]], + ], + ], + ], + ]), + ], [[ + 'content' => 'input { margin: 0; }', + ]]); + + $this->assertSame('FormRecordList', $result['screen']->config[0]['items'][0]['component']); + $this->assertCount(1, $result['nested_screens']); + $this->assertStringContainsString("[selector='items_grid']", (string) $result['screen']->custom_css); + $this->assertStringContainsString('color: red', (string) $result['screen']->custom_css); + $this->assertStringContainsString("[selector='item_name'] input", (string) $result['screen']->custom_css); + $this->assertStringContainsString('margin: 0', (string) $result['screen']->custom_css); + } + + public function testApplyAbeConfigurationSetsPm4AbeAttributes(): void + { + $this->actingAs($this->user); + + $bpmn = preg_replace( + '/pm:screenRef="[^"]*"/', + '', + file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')) + ); + + $process = Process::factory()->create([ + 'user_id' => $this->user->id, + 'bpmn' => $bpmn, + ]); + $emailScreen = $this->writer()->createScreen([ + 'title' => 'ABE Email Screen', + 'config_json' => ['items' => []], + ]); + + $result = $this->writer()->applyAbeConfiguration($process->id, [ + 'abe_configurations' => [[ + 'tas_uid' => 'pm3-task-uid', + 'dyn_uid' => 'dyn-abe-1', + 'pm4_config_email' => [ + 'subject' => 'RE: Approval', + 'requireLogin' => true, + ], + ]], + 'screens' => [[ + 'pm3_uid' => 'dyn-abe-1', + 'pm4_id' => $emailScreen->id, + ]], + 'tasks' => [[ + 'tas_uid' => 'pm3-task-uid', + 'tas_title' => 'New Task', + ]], + ]); + + $this->assertCount(1, $result['applied']); + $this->assertSame((string) $emailScreen->id, $result['applied'][0]['screen_email_ref']); + + $updated = Process::findOrFail($process->id); + $xpath = new \DOMXPath($updated->getDefinitions(true)); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + $node = $xpath->query("//*[@id='node_2']")->item(0); + + $this->assertSame( + 'true', + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'isActionsByEmail') + ); + + $configEmail = json_decode( + $node->getAttributeNS(WorkflowServiceProvider::PROCESS_MAKER_NS, 'configEmail'), + true + ); + $this->assertSame('RE: Approval', $configEmail['subject']); + $this->assertSame($emailScreen->id, $configEmail['screenEmailRef']); + } + + public function testGetMigrationLogReturnsVerificationReport(): void + { + $this->actingAs($this->user); + + $proUid = 'log-report-pro'; + $this->writer()->applyMigrationBundle([ + 'source' => ['pro_uid' => $proUid, 'title' => 'Logged Process'], + 'triggers' => [[ + 'TRI_UID' => 'tri-log', + 'TRI_TITLE' => 'Logged Trigger', + 'TRI_WEBBOT' => 'writer()->getMigrationLog($proUid); + + $this->assertSame('completed', $report['summary']['status']); + $this->assertCount(1, $report['artifacts']['scripts']); + } + + public function testResumeMigrationBundleSkipsCompletedSteps(): void + { + $this->actingAs($this->user); + + $proUid = 'resume-pro-uid'; + $log = MigrationLog::forProUid($proUid); + $log->start(['source' => ['pro_uid' => $proUid, 'title' => 'Resume Process']]); + + $process = $this->writer()->createProcess(['name' => 'Resume Process']); + $log->setProcessId((int) $process->id); + $log->completeStep(MigrationLog::STEP_CREATE_PROCESS); + + $script = $this->writer()->createScript([ + 'title' => 'Resume Script', + 'code' => 'addArtifact('scripts', [ + 'pm3_uid' => 'tri-resume', + 'pm4_id' => $script->id, + 'title' => 'Resume Script', + ]); + $log->completeStep(MigrationLog::STEP_CREATE_SCRIPTS, ['count' => 1]); + + $processCountBefore = Process::count(); + + $result = $this->writer()->resumeMigrationBundle([ + 'source' => ['pro_uid' => $proUid, 'title' => 'Resume Process'], + 'triggers' => [[ + 'TRI_UID' => 'tri-resume', + 'TRI_TITLE' => 'Resume Script', + 'TRI_WEBBOT' => ' [[ + 'DYN_UID' => 'dyn-resume', + 'DYN_TITLE' => 'Resume Screen', + 'DYN_CONTENT' => json_encode([ + 'items' => [[ + 'type' => 'form', + 'items' => [[[[ + 'type' => 'text', + 'variable' => 'field_a', + 'label' => 'Field A', + ]]]], + ]], + ]), + ]], + ]); + + $this->assertSame($process->id, $result['process_id']); + $this->assertSame($processCountBefore, Process::count()); + $this->assertCount(1, $result['scripts']); + $this->assertCount(1, $result['screens']); + $this->assertSame('completed', $result['migration_log']['status']); + } + + public function testApplyMigrationBundleRequiresResumeWhenInterrupted(): void + { + $this->actingAs($this->user); + + $proUid = 'interrupted-pro'; + $log = MigrationLog::forProUid($proUid); + $log->start(['source' => ['pro_uid' => $proUid, 'title' => 'Interrupted']]); + $log->setProcessId(999); + $log->completeStep(MigrationLog::STEP_CREATE_PROCESS); + $log->fail(MigrationLog::STEP_CREATE_SCRIPTS, 'Interrupted'); + + $this->expectException(\Illuminate\Validation\ValidationException::class); + $this->writer()->applyMigrationBundle([ + 'source' => ['pro_uid' => $proUid, 'title' => 'Interrupted'], + ]); + } +} diff --git a/tests/unit/Mcp/McpSupportTest.php b/tests/unit/Mcp/McpSupportTest.php new file mode 100644 index 0000000000..75e4f1c9f8 --- /dev/null +++ b/tests/unit/Mcp/McpSupportTest.php @@ -0,0 +1,51 @@ +assertSame(McpSupport::MCP_VERSION, $report['mcp_version']); + $this->assertNotEmpty($report['supported_features']); + + $this->assertFalse( + $report['platform_drift_detected'], + $this->platformDriftFailureMessage($report), + ); + } + + /** + * @param array $report + */ + private function platformDriftFailureMessage(array $report): string + { + $drift = $report['platform_drift'] ?? []; + + if ($drift === []) { + return 'platform_drift_detected is true but platform_drift is empty. Review McpSupport::report().'; + } + + $lines = [ + 'MCP is behind PM4 core. Update ProcessMaker/Mcp and McpSupport::platformChecks().', + 'PM version: ' . ($report['pm_version'] ?? 'unknown'), + 'Drift:', + ]; + + foreach ($drift as $item) { + $lines[] = sprintf(' - %s: %s', $item['id'] ?? 'unknown', $item['detail'] ?? ''); + } + + $lines[] = 'Agent tool: get_mcp_capabilities'; + + return implode("\n", $lines); + } +} diff --git a/tests/unit/Mcp/Migration/BpmnEnhancerTest.php b/tests/unit/Mcp/Migration/BpmnEnhancerTest.php new file mode 100644 index 0000000000..3167792ed3 --- /dev/null +++ b/tests/unit/Mcp/Migration/BpmnEnhancerTest.php @@ -0,0 +1,51 @@ +create(); + $child = Process::factory()->create(['user_id' => $user->id]); + $bpmn = file_get_contents(base_path('tests/Fixtures/single_task_with_screen.bpmn')); + $process = Process::factory()->create([ + 'user_id' => $user->id, + 'bpmn' => $bpmn, + ]); + + $result = (new BpmnEnhancer())->apply($process, [ + 'task_element_map' => [[ + 'tas_uid' => 'pm3-task-uid', + 'element_id' => 'node_2', + ]], + 'timer_events' => [[ + 'act_uid' => 'pm3-task-uid', + 'hour' => 2, + ]], + 'sub_processes' => [[ + 'tas_uid' => 'pm3-task-uid', + 'pro_uid' => 'child-pro-uid', + ]], + 'subprocess_map' => [ + 'child-pro-uid' => (string) $child->id, + ], + ]); + + $process->refresh(); + $xpath = new \DOMXPath($process->getDefinitions(true)); + $xpath->registerNamespace('bpmn', 'http://www.omg.org/spec/BPMN/20100524/MODEL'); + $xpath->registerNamespace('pm', WorkflowServiceProvider::PROCESS_MAKER_NS); + + $this->assertCount(1, $result['applied_timers']); + $this->assertCount(1, $result['applied_sub_processes']); + $this->assertSame(1, $xpath->query("//bpmn:boundaryEvent[@attachedToRef='node_2']/bpmn:timerEventDefinition")->length); + $this->assertSame(1, $xpath->query("//bpmn:callActivity[@id='node_2']")->length); + } +} diff --git a/tests/unit/Mcp/Migration/CssScopeConverterTest.php b/tests/unit/Mcp/Migration/CssScopeConverterTest.php new file mode 100644 index 0000000000..36887eb15e --- /dev/null +++ b/tests/unit/Mcp/Migration/CssScopeConverterTest.php @@ -0,0 +1,84 @@ + 'invoice_number', + 'component' => 'FormInput', + 'pm3_type' => 'text', + 'pm3_id' => 'invoice_number', + 'pm3_name' => null, + 'pm3_variable' => 'invoice_number', + ]]; + + $result = $converter->convert('#invoice_number { color: red; font-size: 14px; }', $registry); + + $this->assertStringContainsString("[selector='invoice_number']", (string) $result['css']); + $this->assertStringContainsString("[selector='invoice_number'] input", (string) $result['css']); + $this->assertStringContainsString('color: red', (string) $result['css']); + $this->assertStringNotContainsString('font-size', (string) $result['css']); + } + + public function testScopesTagSelectorsByControlType(): void + { + $converter = new CssScopeConverter(); + $registry = [ + [ + 'selector' => 'status', + 'component' => 'FormSelect', + 'pm3_type' => 'dropdown', + 'pm3_id' => null, + 'pm3_name' => null, + 'pm3_variable' => 'status', + ], + [ + 'selector' => 'notes', + 'component' => 'FormTextArea', + 'pm3_type' => 'textarea', + 'pm3_id' => null, + 'pm3_name' => null, + 'pm3_variable' => 'notes', + ], + ]; + + $result = $converter->convert("select { border: 1px solid #ccc; }\ntextarea { min-height: 80px; }", $registry); + + $this->assertStringContainsString("[selector='status'] select", (string) $result['css']); + $this->assertStringContainsString('border: 1px solid #ccc', (string) $result['css']); + $this->assertStringContainsString("[selector='notes'] textarea", (string) $result['css']); + $this->assertStringContainsString('min-height: 80px', (string) $result['css']); + } + + public function testSkipsPm3SkinNoiseSelectors(): void + { + $converter = new CssScopeConverter(); + + $result = $converter->convert('.x-form-invalid { color: red; }', []); + + $this->assertNull($result['css']); + $this->assertNotEmpty($result['warnings']); + } + + public function testBuildInlineFieldCssUsesScopedSelectors(): void + { + $converter = new CssScopeConverter(); + + $css = $converter->buildInlineFieldCss([ + 'type' => 'text', + 'textTransform' => 'uppercase', + 'colSpan' => 6, + ], 'customer_name'); + + $this->assertStringContainsString("[selector='customer_name']", (string) $css); + $this->assertStringContainsString('text-transform: uppercase', (string) $css); + $this->assertStringContainsString('width: 50%', (string) $css); + } +} diff --git a/tests/unit/Mcp/Migration/DynaformConverterTest.php b/tests/unit/Mcp/Migration/DynaformConverterTest.php new file mode 100644 index 0000000000..c7c323c42f --- /dev/null +++ b/tests/unit/Mcp/Migration/DynaformConverterTest.php @@ -0,0 +1,142 @@ +toScreenConfig([ + 'DYN_TITLE' => 'Invoice Form', + 'DYN_CONTENT' => json_encode([ + 'css' => '#invoice_number { color: navy; }', + 'name' => 'Invoice Form', + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [ + [ + 'type' => 'text', + 'variable' => 'invoice_number', + 'label' => 'Invoice Number', + 'required' => true, + ], + [ + 'type' => 'textarea', + 'variable' => 'notes', + 'label' => 'Notes', + ], + ], + ], + ], + ], + ]), + ]); + + $this->assertSame('Invoice Form', $result['config'][0]['name']); + $this->assertCount(3, $result['config'][0]['items']); + $this->assertSame('FormInput', $result['config'][0]['items'][0]['component']); + $this->assertSame('FormTextArea', $result['config'][0]['items'][1]['component']); + $this->assertSame('FormButton', $result['config'][0]['items'][2]['component']); + $this->assertSame('required', $result['config'][0]['items'][0]['config']['validation']); + $this->assertSame('invoice_number', $result['config'][0]['items'][0]['config']['customCssSelector']); + $this->assertStringContainsString("[selector='invoice_number']", (string) $result['custom_css']); + $this->assertStringContainsString('color: navy', (string) $result['custom_css']); + $this->assertSame([], $result['warnings']); + } + + public function testAddsPlaceholderWhenNoSupportedControlsExist(): void + { + $converter = new DynaformConverter(); + + $result = $converter->toScreenConfig([ + 'DYN_TITLE' => 'Title Only', + 'DYN_CONTENT' => json_encode([ + 'items' => [ + ['type' => 'title', 'label' => 'Header'], + ], + ]), + ]); + + $this->assertCount(2, $result['config'][0]['items']); + $this->assertSame('FormButton', $result['config'][0]['items'][1]['component']); + $this->assertNotEmpty($result['warnings']); + } + + public function testConvertsRadioAndHiddenControls(): void + { + $converter = new DynaformConverter(); + + $result = $converter->toScreenConfig([ + 'DYN_TITLE' => 'Mixed Form', + 'DYN_CONTENT' => json_encode([ + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [[ + 'type' => 'radio', + 'variable' => 'priority', + 'label' => 'Priority', + 'options' => [ + ['value' => 'high', 'label' => 'High'], + ], + ]], + [[ + 'type' => 'hidden', + 'variable' => 'token', + 'label' => 'Token', + ]], + ], + ], + ], + ]), + ]); + + $this->assertSame('FormSelect', $result['config'][0]['items'][0]['component']); + $this->assertTrue($result['config'][0]['items'][1]['config']['hidden']); + } + + public function testExtractsGridDefinitionsWithoutFlattening(): void + { + $converter = new DynaformConverter(); + + $result = $converter->toScreenConfig([ + 'DYN_TITLE' => 'Grid Form', + 'DYN_CONTENT' => json_encode([ + 'items' => [ + [ + 'type' => 'form', + 'items' => [ + [[ + 'type' => 'grid', + 'variable' => 'items_grid', + 'label' => 'Items', + 'columns' => [ + [ + 'type' => 'text', + 'variable' => 'item_name', + 'label' => 'Item Name', + ], + ], + ]], + ], + ], + ], + ]), + ]); + + $this->assertCount(1, $result['grid_definitions']); + $this->assertSame('items_grid', $result['grid_definitions'][0]['variable']); + + $columns = $converter->convertGridColumns($result['grid_definitions'][0]); + $this->assertSame('FormInput', $columns['items'][0]['component']); + $this->assertSame('item_name', $columns['options_list'][0]['value']); + } +} diff --git a/tests/unit/Mcp/Migration/MigrationBpmnTest.php b/tests/unit/Mcp/Migration/MigrationBpmnTest.php new file mode 100644 index 0000000000..ad9774812e --- /dev/null +++ b/tests/unit/Mcp/Migration/MigrationBpmnTest.php @@ -0,0 +1,182 @@ + + + + + 2466207176a357b8d420422029986737 + + + 2466207176a357b8d420422029986737 + 7614540406aa1b70c1739a6064656012 + + + + +XML; + + $result = MigrationBpmn::sanitizeXmlIds($xml); + + $this->assertNotSame([], $result['id_map']); + $this->assertStringNotContainsString('4831240716a357b8d408417073978576', $result['xml']); + $this->assertStringContainsString('sourceRef="node_', $result['xml']); + $this->assertStringContainsString('node_', $result['xml']); + } + + public function testRemapBundleElementIdsUpdatesTaskUids(): void + { + $bundle = MigrationBpmn::remapBundleElementIds([ + 'tasks' => [['tas_uid' => '4154827546a357b8d3ce9d1035298340']], + 'task_element_map' => [[ + 'tas_uid' => '4154827546a357b8d3ce9d1035298340', + 'element_id' => '4154827546a357b8d3ce9d1035298340', + ]], + ], [ + '4154827546a357b8d3ce9d1035298340' => 'node_task1', + ]); + + $this->assertSame('node_task1', $bundle['tasks'][0]['tas_uid']); + $this->assertSame('node_task1', $bundle['task_element_map'][0]['element_id']); + } + + public function testRemapBundleElementIdsUpdatesStepTaskUids(): void + { + $bundle = MigrationBpmn::remapBundleElementIds([ + 'steps' => [[ + 'step_type' => 'DYNAFORM', + 'tas_uid' => '4154827546a357b8d3ce9d1035298340', + 'dyn_uid' => '9089870096aa1b700247d05097963765', + ]], + ], [ + '4154827546a357b8d3ce9d1035298340' => 'node_task1', + ]); + + $this->assertSame('node_task1', $bundle['steps'][0]['tas_uid']); + } + + public function testRemapBundleElementIdsBuildsTaskElementMapFromTasksWhenMissing(): void + { + $bundle = MigrationBpmn::remapBundleElementIds([ + 'tasks' => [['tas_uid' => '4154827546a357b8d3ce9d1035298340']], + ], [ + '4154827546a357b8d3ce9d1035298340' => 'node_task1', + ]); + + $this->assertSame('node_task1', $bundle['task_element_map'][0]['tas_uid']); + $this->assertSame('node_task1', $bundle['task_element_map'][0]['element_id']); + } + + public function testRebuildBpmnDiagramReplacesStaleLayout(): void + { + $xml = <<<'XML' + + + + + flow_1 + + + flow_1 + flow_2 + + + flow_2 + flow_3 + + + flow_3 + + + + + + + + + + + + + + + + + +XML; + + $result = BpmnDiagramInterchange::rebuild($xml); + + $this->assertStringContainsString('BPMNShape_task_1', $result); + $this->assertStringContainsString('BPMNShape_task_2', $result); + $this->assertStringContainsString('BPMNEdge_flow_2', $result); + $this->assertStringContainsString('BPMNEdge_flow_3', $result); + $this->assertStringNotContainsString('old_diagram', $result); + $this->assertMatchesRegularExpression( + '/BPMNShape_start[^>]+>.*?x="120".*?BPMNShape_task_1[^>]+>.*?x="300"/s', + $result, + ); + } + + public function testEnsureBpmnDiagramAddsDiagramInterchange(): void + { + $xml = <<<'XML' + + + + + flow_1 + + + flow_1 + + + + +XML; + + $result = BpmnDiagramInterchange::ensure($xml); + + $this->assertStringContainsString('bpmndi:BPMNDiagram', $result); + $this->assertStringContainsString('BPMNShape_start', $result); + $this->assertStringContainsString('BPMNEdge_flow_1', $result); + } + + public function testSanitizeXmlIdsRemapsDiagramBpmnElementReferences(): void + { + $xml = <<<'XML' + + + + + + + + + + + +XML; + + $result = MigrationBpmn::sanitizeXmlIds($xml); + + $this->assertStringContainsString('bpmnElement="node_', $result['xml']); + $this->assertStringNotContainsString('4831240716a357b8d408417073978576', $result['xml']); + } +} diff --git a/tests/unit/Mcp/Migration/MigrationGapReportTest.php b/tests/unit/Mcp/Migration/MigrationGapReportTest.php new file mode 100644 index 0000000000..0d0c1ddfd3 --- /dev/null +++ b/tests/unit/Mcp/Migration/MigrationGapReportTest.php @@ -0,0 +1,20 @@ +build( + ['variables' => [['var_name' => 'total']]], + ['validation' => ['valid' => false], 'applied_variables' => []] + ); + + $this->assertFalse($report['ready']); + $this->assertNotEmpty($report['gaps']); + } +} diff --git a/tests/unit/Mcp/Migration/MigrationLogTest.php b/tests/unit/Mcp/Migration/MigrationLogTest.php new file mode 100644 index 0000000000..6aee7f0232 --- /dev/null +++ b/tests/unit/Mcp/Migration/MigrationLogTest.php @@ -0,0 +1,83 @@ +start([ + 'source' => [ + 'pro_uid' => 'pro-123', + 'title' => 'Invoice Approval', + ], + ]); + + $log->setProcessId(42); + $log->completeStep(MigrationLog::STEP_CREATE_PROCESS); + + $loaded = MigrationLog::load('pro-123'); + $this->assertNotNull($loaded); + $this->assertTrue($loaded->isStepDone(MigrationLog::STEP_CREATE_PROCESS)); + $this->assertSame(42, $loaded->getProcessId()); + $this->assertSame('in_progress', $loaded->getStatus()); + } + + public function testFailMarksLogAsResumable(): void + { + $log = MigrationLog::forProUid('pro-456'); + $log->start(['source' => ['pro_uid' => 'pro-456', 'title' => 'Broken']]); + $log->setProcessId(10); + $log->completeStep(MigrationLog::STEP_CREATE_PROCESS); + $log->fail(MigrationLog::STEP_CREATE_SCRIPTS, 'Script error'); + + $loaded = MigrationLog::load('pro-456'); + $this->assertNotNull($loaded); + $this->assertSame('failed', $loaded->getStatus()); + $this->assertTrue($loaded->canResume()); + $this->assertSame('Script error', $loaded->toSummary()['error']); + } + + public function testArtifactsArePersisted(): void + { + $log = MigrationLog::forProUid('pro-789'); + $log->start(['source' => ['pro_uid' => 'pro-789', 'title' => 'Artifacts']]); + $log->addArtifact('scripts', [ + 'pm3_uid' => 'tri-1', + 'pm4_id' => 5, + 'title' => 'Trigger', + ]); + + $loaded = MigrationLog::load('pro-789'); + $this->assertCount(1, $loaded->getArtifacts('scripts')); + $this->assertSame('tri-1', $loaded->getArtifacts('scripts')[0]['pm3_uid']); + } + + public function testListAllReturnsSummaries(): void + { + $first = MigrationLog::forProUid('pro-a'); + $first->start(['source' => ['pro_uid' => 'pro-a', 'title' => 'A']]); + $first->setProcessId(1); + + $second = MigrationLog::forProUid('pro-b'); + $second->start(['source' => ['pro_uid' => 'pro-b', 'title' => 'B']]); + $second->setProcessId(2); + + $logs = MigrationLog::listAll(); + $this->assertCount(2, $logs); + $proUids = array_column($logs, 'pro_uid'); + $this->assertContains('pro-a', $proUids); + $this->assertContains('pro-b', $proUids); + } +} diff --git a/tests/unit/Mcp/Migration/ProcessVariableMigratorTest.php b/tests/unit/Mcp/Migration/ProcessVariableMigratorTest.php new file mode 100644 index 0000000000..f29929edea --- /dev/null +++ b/tests/unit/Mcp/Migration/ProcessVariableMigratorTest.php @@ -0,0 +1,31 @@ +create(); + $process = Process::factory()->create(['user_id' => $user->id]); + + $result = (new ProcessVariableMigrator())->apply($process, [[ + 'var_name' => 'invoice_total', + 'var_label' => 'Invoice Total', + 'var_field_type' => 'FLOAT', + 'var_default' => '10.5', + ]]); + + $process->refresh(); + + $this->assertSame(['invoice_total'], $result['applied']); + $this->assertSame('invoice_total', $process->properties['variables'][0]['name']); + $this->assertSame('float', $process->properties['variables'][0]['type']); + $this->assertSame(10.5, $process->properties['request_data_defaults']['invoice_total']); + } +} diff --git a/tests/unit/Mcp/Migration/TriggerTranslatorTest.php b/tests/unit/Mcp/Migration/TriggerTranslatorTest.php new file mode 100644 index 0000000000..79e231962a --- /dev/null +++ b/tests/unit/Mcp/Migration/TriggerTranslatorTest.php @@ -0,0 +1,19 @@ +translate('assertStringContainsString("\$data['approved'] = \"YES\"", $result); + $this->assertStringContainsString('$api->newCase(', $result); + } +} diff --git a/tests/unit/Mcp/Migration/WriterServerTest.php b/tests/unit/Mcp/Migration/WriterServerTest.php new file mode 100644 index 0000000000..809fa1e3e6 --- /dev/null +++ b/tests/unit/Mcp/Migration/WriterServerTest.php @@ -0,0 +1,33 @@ +createContext(); + $request = new JsonRpcRequest(1, 'tools/list', ['per_page' => 15]); + + $response = (new ListAllTools())->handle($request, $context); + $payload = $response->toArray(); + + $tools = $payload['result']['tools']; + $names = array_column($tools, 'name'); + + $this->assertGreaterThanOrEqual(30, count($tools)); + $this->assertArrayNotHasKey('nextCursor', $payload['result']); + $this->assertContains('apply_migration_bundle', $names); + $this->assertContains('validate_process', $names); + $this->assertContains('link_screen_to_task', $names); + } +} diff --git a/tests/unit/Mcp/Process/ProcessAgentTest.php b/tests/unit/Mcp/Process/ProcessAgentTest.php new file mode 100644 index 0000000000..5b13224009 --- /dev/null +++ b/tests/unit/Mcp/Process/ProcessAgentTest.php @@ -0,0 +1,222 @@ +actingAs($this->user); + Process::factory()->create(['user_id' => $this->user->id, 'name' => 'Agent Test Process']); + + $result = app(Reader::class)->listProcesses(filterName: 'Agent Test'); + + $this->assertGreaterThanOrEqual(1, $result['total']); + $this->assertStringContainsString('Agent Test', $result['processes'][0]['name']); + } + + public function testAnalyzerFindsTasksWithoutScreen(): void + { + $this->actingAs($this->user); + + $process = app(Writer::class)->createProcess(['name' => 'Analyze Me']); + $analysis = app(Analyzer::class)->analyze((string) $process->id); + + $this->assertSame($process->id, $analysis['process_id']); + $this->assertArrayHasKey('findings', $analysis); + $this->assertArrayHasKey('suggestions', $analysis); + } + + public function testApplyProcessDesignCreatesProcessWithScreen(): void + { + $this->actingAs($this->user); + + $result = app(Designer::class)->applyDesignPlan([ + 'process' => ['name' => 'Designed Via MCP', 'description' => 'Agent design'], + 'bpmn_template' => 'SingleTask', + 'screens' => [[ + 'title' => 'Request Form', + 'fields' => [[ + 'variable' => 'request_title', + 'label' => 'Title', + 'type' => 'text', + 'required' => true, + ]], + ]], + 'variables' => [[ + 'var_name' => 'request_title', + 'var_default' => '', + ]], + ]); + + $this->assertNotEmpty($result['process_id']); + $this->assertCount(1, $result['screens']); + $this->assertNotEmpty($result['linked_screens']); + $this->assertTrue($result['validation']['valid']); + } + + public function testScreenControlCatalogHasRequiredComponents(): void + { + $this->assertSame([], ScreenControlCatalog::missingComponents()); + $report = ScreenControlCatalog::catalogReport(); + $this->assertArrayHasKey('FormInput', $report); + $this->assertArrayHasKey('FormButton', $report); + $this->assertGreaterThan(0, $report['FormInput']['inspector_fields']); + } + + public function testScreenBuilderCreatesFormInputConfig(): void + { + $builder = app(ScreenBuilder::class); + $config = $builder->buildFormConfig([[ + 'variable' => 'customer_name', + 'label' => 'Customer', + 'type' => 'text', + ]], 'Customer Form'); + + $this->assertSame('Customer Form', $config[0]['name']); + $this->assertSame('FormInput', $config[0]['items'][0]['component']); + $this->assertSame('customer_name', $config[0]['items'][0]['config']['name']); + $this->assertSame('string', $config[0]['items'][0]['config']['dataFormat']); + $this->assertNotEmpty($config[0]['items'][0]['uuid']); + $this->assertNotEmpty($config[0]['items'][0]['inspector']); + $this->assertSame('FormInput', $config[0]['items'][0]['editor-component']); + $this->assertSame([], $config[0]['computed']); + $this->assertSame('FormButton', $config[0]['items'][1]['component']); + $this->assertTrue($config[0]['items'][1]['config']['defaultSubmit']); + + $validation = $builder->validateScreenConfig($config); + $this->assertTrue($validation['valid']); + } + + public function testAnalyzerFlagsScreenWithoutSubmit(): void + { + $builder = app(ScreenBuilder::class); + $issues = $builder->validateScreenConfig([[ + 'name' => 'Broken', + 'computed' => [], + 'items' => [[ + 'uuid' => '00000000-0000-0000-0000-000000000001', + 'label' => 'Name', + 'component' => 'FormInput', + 'config' => ['name' => 'name', 'label' => 'Name'], + ]], + ]]); + + $this->assertFalse($issues['valid']); + $codes = array_column($issues['issues'], 'code'); + $this->assertContains('missing_submit_button', $codes); + $this->assertContains('field_missing_inspector', $codes); + } + + public function testWriterNormalizesBrokenAgentScreenConfig(): void + { + $this->actingAs($this->user); + + $result = app(Writer::class)->createScreenResult([ + 'title' => 'Broken Agent Screen', + 'description' => 'Should be normalized', + 'config_json' => [[ + 'name' => 'data', + 'items' => [[ + 'name' => 'pet_name', + 'label' => 'Pet Name', + 'component' => 'FormInput', + 'config' => [ + 'name' => 'pet_name', + 'type' => 'text', + 'label' => 'Pet Name', + 'validation' => 'required', + ], + ], [ + 'name' => 'pet_species', + 'label' => 'Species', + 'component' => 'FormSelectList', + 'config' => [ + 'name' => 'pet_species', + 'label' => 'Species', + 'validation' => 'required', + ], + ]], + ]], + ]); + + $item = $result->screen->config[0]['items'][0]; + $this->assertNotEmpty($item['uuid']); + $this->assertNotEmpty($item['inspector']); + $this->assertSame('FormInput', $item['editor-component']); + $this->assertSame('FormSelect', $result->screen->config[0]['items'][1]['component']); + $this->assertSame('FormButton', $result->screen->config[0]['items'][2]['component']); + $this->assertSame([], $result->screen->config[0]['computed']); + } + + public function testPlatformWriterCreatesScreenViaImport(): void + { + $this->actingAs($this->user); + + $config = app(ScreenBuilder::class)->buildFormConfig([[ + 'variable' => 'platform_field', + 'label' => 'Platform Field', + 'type' => 'text', + ]], 'Platform Screen'); + + $result = app(\ProcessMaker\Mcp\Platform\PlatformWriter::class)->createScreenResult([ + 'title' => 'Platform Import Screen', + 'description' => 'Created through ImportScreen', + 'config_json' => $config, + ]); + + $this->assertNotEmpty($result->screen->id); + $this->assertSame('Platform Import Screen', $result->screen->title); + $this->assertSame('import_screen', $result->importMethod); + $this->assertSame([], $result->warnings); + $this->assertSame('FormInput', $result->screen->config[0]['items'][0]['component']); + $this->assertNotEmpty($result->screen->config[0]['items'][0]['inspector']); + } + + public function testCreateProcessAssignsCategoryAndStartPermissions(): void + { + $this->actingAs($this->user); + + $bpmn = file_get_contents(database_path('processes/templates/SingleTask.bpmn')); + $process = app(Writer::class)->createProcess([ + 'name' => 'Startable MCP Process', + 'bpmn' => $bpmn, + ]); + + $this->assertGreaterThan(0, $process->categories()->count()); + $this->assertGreaterThan(0, $process->usersCanStart('StartEventUID')->count()); + $this->assertSame(1, Process::nonSystem()->active()->categoryStatus('ACTIVE')->where('id', $process->id)->count()); + } + + public function testBpmnDesignerAddsUserTaskOnSingleTaskTemplate(): void + { + $this->actingAs($this->user); + + $bpmn = file_get_contents(database_path('processes/templates/SingleTask.bpmn')); + $process = app(Writer::class)->createProcess([ + 'name' => 'BPMN Design', + 'bpmn' => $bpmn, + ]); + + $added = app(BpmnDesigner::class)->addUserTask((string) $process->id, 'Review Step'); + $inventory = app(Reader::class)->getProcessInventory((string) $process->id); + + $this->assertNotEmpty($added['element_id']); + $this->assertSame(2, $inventory['counts']['tasks']); + $this->assertTrue(app(Writer::class)->validateProcess((string) $process->id)['valid']); + } +}