zyflow 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-flow/metrics/agent-metrics.json +1 -0
- package/.claude-flow/metrics/performance.json +87 -0
- package/.claude-flow/metrics/system-metrics.json +4370 -0
- package/.claude-flow/metrics/task-metrics.json +10 -0
- package/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +17 -0
- package/.gitleaks.toml +69 -0
- package/.hive-mind/config/queens.json +59 -0
- package/.hive-mind/config/workers.json +72 -0
- package/.hive-mind/config.json +111 -0
- package/.hive-mind/hive.db +0 -0
- package/.hive-mind/hive.db-shm +0 -0
- package/.hive-mind/hive.db-wal +0 -0
- package/.leann/indexes/zyflow/documents.ids.txt +2078 -0
- package/.leann/indexes/zyflow/documents.index +0 -0
- package/.leann/indexes/zyflow/documents.leann.meta.json +25 -0
- package/.leann/indexes/zyflow/documents.leann.passages.idx +0 -0
- package/.leann/indexes/zyflow/documents.leann.passages.jsonl +2078 -0
- package/.mcp.json +41 -0
- package/.moai-backups/20260126_231508/.mcp.json +11 -0
- package/.moai-backups/20260126_231508/backup_metadata.json +34 -0
- package/.moai-backups/20260129_145438/.mcp.json +41 -0
- package/.moai-backups/20260129_145438/backup_metadata.json +53 -0
- package/.moai-backups/20260129_145504/.mcp.json +41 -0
- package/.moai-backups/20260129_145504/backup_metadata.json +20 -0
- package/.moai-backups/20260201_140004/.mcp.json +41 -0
- package/.moai-backups/20260201_140004/backup_metadata.json +51 -0
- package/.moai-backups/backup/.mcp.json +12 -0
- package/.moai-backups/settings-backup/settings.local.json +61 -0
- package/.pre-commit-config.yaml +74 -0
- package/.prettierignore +3 -0
- package/.prettierrc +7 -0
- package/.scannerwork/.sonar_lock +0 -0
- package/.scannerwork/report-task.txt +6 -0
- package/.serena/project.yml +105 -0
- package/.shadcn-admin-ref/.env.example +1 -0
- package/.shadcn-admin-ref/.prettierignore +18 -0
- package/.shadcn-admin-ref/.prettierrc +50 -0
- package/.shadcn-admin-ref/LICENSE +21 -0
- package/.shadcn-admin-ref/components.json +21 -0
- package/.shadcn-admin-ref/cz.yaml +7 -0
- package/.shadcn-admin-ref/eslint.config.js +59 -0
- package/.shadcn-admin-ref/index.html +80 -0
- package/.shadcn-admin-ref/knip.config.ts +8 -0
- package/.shadcn-admin-ref/netlify.toml +4 -0
- package/.shadcn-admin-ref/package.json +83 -0
- package/.shadcn-admin-ref/public/images/favicon.png +0 -0
- package/.shadcn-admin-ref/public/images/favicon.svg +4 -0
- package/.shadcn-admin-ref/public/images/favicon_light.png +0 -0
- package/.shadcn-admin-ref/public/images/favicon_light.svg +1 -0
- package/.shadcn-admin-ref/public/images/shadcn-admin.png +0 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-discord.tsx +28 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-docker.tsx +33 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-facebook.tsx +25 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-figma.tsx +27 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-github.tsx +25 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-gitlab.tsx +25 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-gmail.tsx +28 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-medium.tsx +30 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-notion.tsx +28 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-skype.tsx +26 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-slack.tsx +28 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-stripe.tsx +25 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-telegram.tsx +25 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-trello.tsx +27 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-whatsapp.tsx +26 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/icon-zoom.tsx +26 -0
- package/.shadcn-admin-ref/src/assets/brand-icons/index.ts +16 -0
- package/.shadcn-admin-ref/src/assets/clerk-full-logo.tsx +41 -0
- package/.shadcn-admin-ref/src/assets/clerk-logo.tsx +23 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-dir.tsx +110 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-layout-compact.tsx +131 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-layout-default.tsx +124 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-layout-full.tsx +100 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-floating.tsx +82 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-inset.tsx +58 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-sidebar.tsx +53 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-theme-dark.tsx +79 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-theme-light.tsx +78 -0
- package/.shadcn-admin-ref/src/assets/custom/icon-theme-system.tsx +116 -0
- package/.shadcn-admin-ref/src/assets/logo.tsx +24 -0
- package/.shadcn-admin-ref/src/components/coming-soon.tsx +16 -0
- package/.shadcn-admin-ref/src/components/command-menu.tsx +91 -0
- package/.shadcn-admin-ref/src/components/config-drawer.tsx +354 -0
- package/.shadcn-admin-ref/src/components/confirm-dialog.tsx +67 -0
- package/.shadcn-admin-ref/src/components/data-table/bulk-actions.tsx +213 -0
- package/.shadcn-admin-ref/src/components/data-table/column-header.tsx +74 -0
- package/.shadcn-admin-ref/src/components/data-table/faceted-filter.tsx +146 -0
- package/.shadcn-admin-ref/src/components/data-table/index.ts +6 -0
- package/.shadcn-admin-ref/src/components/data-table/pagination.tsx +130 -0
- package/.shadcn-admin-ref/src/components/data-table/toolbar.tsx +85 -0
- package/.shadcn-admin-ref/src/components/data-table/view-options.tsx +56 -0
- package/.shadcn-admin-ref/src/components/date-picker.tsx +51 -0
- package/.shadcn-admin-ref/src/components/layout/app-sidebar.tsx +37 -0
- package/.shadcn-admin-ref/src/components/layout/app-title.tsx +64 -0
- package/.shadcn-admin-ref/src/components/layout/authenticated-layout.tsx +42 -0
- package/.shadcn-admin-ref/src/components/layout/data/sidebar-data.ts +205 -0
- package/.shadcn-admin-ref/src/components/layout/header.tsx +50 -0
- package/.shadcn-admin-ref/src/components/layout/main.tsx +27 -0
- package/.shadcn-admin-ref/src/components/layout/nav-group.tsx +185 -0
- package/.shadcn-admin-ref/src/components/layout/nav-user.tsx +124 -0
- package/.shadcn-admin-ref/src/components/layout/team-switcher.tsx +86 -0
- package/.shadcn-admin-ref/src/components/layout/top-nav.tsx +67 -0
- package/.shadcn-admin-ref/src/components/layout/types.ts +44 -0
- package/.shadcn-admin-ref/src/components/learn-more.tsx +44 -0
- package/.shadcn-admin-ref/src/components/long-text.tsx +84 -0
- package/.shadcn-admin-ref/src/components/navigation-progress.tsx +25 -0
- package/.shadcn-admin-ref/src/components/password-input.tsx +42 -0
- package/.shadcn-admin-ref/src/components/profile-dropdown.tsx +75 -0
- package/.shadcn-admin-ref/src/components/search.tsx +37 -0
- package/.shadcn-admin-ref/src/components/select-dropdown.tsx +62 -0
- package/.shadcn-admin-ref/src/components/sign-out-dialog.tsx +38 -0
- package/.shadcn-admin-ref/src/components/skip-to-main.tsx +10 -0
- package/.shadcn-admin-ref/src/components/theme-switch.tsx +58 -0
- package/.shadcn-admin-ref/src/components/ui/alert-dialog.tsx +154 -0
- package/.shadcn-admin-ref/src/components/ui/alert.tsx +65 -0
- package/.shadcn-admin-ref/src/components/ui/avatar.tsx +50 -0
- package/.shadcn-admin-ref/src/components/ui/badge.tsx +45 -0
- package/.shadcn-admin-ref/src/components/ui/button.tsx +58 -0
- package/.shadcn-admin-ref/src/components/ui/calendar.tsx +210 -0
- package/.shadcn-admin-ref/src/components/ui/card.tsx +91 -0
- package/.shadcn-admin-ref/src/components/ui/checkbox.tsx +29 -0
- package/.shadcn-admin-ref/src/components/ui/collapsible.tsx +31 -0
- package/.shadcn-admin-ref/src/components/ui/command.tsx +181 -0
- package/.shadcn-admin-ref/src/components/ui/dialog.tsx +142 -0
- package/.shadcn-admin-ref/src/components/ui/dropdown-menu.tsx +254 -0
- package/.shadcn-admin-ref/src/components/ui/form.tsx +164 -0
- package/.shadcn-admin-ref/src/components/ui/input-otp.tsx +74 -0
- package/.shadcn-admin-ref/src/components/ui/input.tsx +20 -0
- package/.shadcn-admin-ref/src/components/ui/label.tsx +23 -0
- package/.shadcn-admin-ref/src/components/ui/popover.tsx +45 -0
- package/.shadcn-admin-ref/src/components/ui/radio-group.tsx +42 -0
- package/.shadcn-admin-ref/src/components/ui/scroll-area.tsx +65 -0
- package/.shadcn-admin-ref/src/components/ui/select.tsx +182 -0
- package/.shadcn-admin-ref/src/components/ui/separator.tsx +25 -0
- package/.shadcn-admin-ref/src/components/ui/sheet.tsx +136 -0
- package/.shadcn-admin-ref/src/components/ui/sidebar.tsx +728 -0
- package/.shadcn-admin-ref/src/components/ui/skeleton.tsx +13 -0
- package/.shadcn-admin-ref/src/components/ui/sonner.tsx +21 -0
- package/.shadcn-admin-ref/src/components/ui/switch.tsx +28 -0
- package/.shadcn-admin-ref/src/components/ui/table.tsx +113 -0
- package/.shadcn-admin-ref/src/components/ui/tabs.tsx +63 -0
- package/.shadcn-admin-ref/src/components/ui/textarea.tsx +17 -0
- package/.shadcn-admin-ref/src/components/ui/tooltip.tsx +60 -0
- package/.shadcn-admin-ref/src/config/fonts.ts +19 -0
- package/.shadcn-admin-ref/src/context/direction-provider.tsx +61 -0
- package/.shadcn-admin-ref/src/context/font-provider.tsx +58 -0
- package/.shadcn-admin-ref/src/context/layout-provider.tsx +85 -0
- package/.shadcn-admin-ref/src/context/search-provider.tsx +46 -0
- package/.shadcn-admin-ref/src/context/theme-provider.tsx +110 -0
- package/.shadcn-admin-ref/src/features/apps/data/apps.tsx +110 -0
- package/.shadcn-admin-ref/src/features/apps/index.tsx +179 -0
- package/.shadcn-admin-ref/src/features/auth/auth-layout.tsx +19 -0
- package/.shadcn-admin-ref/src/features/auth/forgot-password/components/forgot-password-form.tsx +82 -0
- package/.shadcn-admin-ref/src/features/auth/forgot-password/index.tsx +44 -0
- package/.shadcn-admin-ref/src/features/auth/otp/components/otp-form.tsx +100 -0
- package/.shadcn-admin-ref/src/features/auth/otp/index.tsx +44 -0
- package/.shadcn-admin-ref/src/features/auth/sign-in/assets/dashboard-dark.png +0 -0
- package/.shadcn-admin-ref/src/features/auth/sign-in/assets/dashboard-light.png +0 -0
- package/.shadcn-admin-ref/src/features/auth/sign-in/components/user-auth-form.tsx +150 -0
- package/.shadcn-admin-ref/src/features/auth/sign-in/index.tsx +51 -0
- package/.shadcn-admin-ref/src/features/auth/sign-in/sign-in-2.tsx +69 -0
- package/.shadcn-admin-ref/src/features/auth/sign-up/components/sign-up-form.tsx +143 -0
- package/.shadcn-admin-ref/src/features/auth/sign-up/index.tsx +57 -0
- package/.shadcn-admin-ref/src/features/chats/components/new-chat.tsx +127 -0
- package/.shadcn-admin-ref/src/features/chats/data/chat-types.ts +4 -0
- package/.shadcn-admin-ref/src/features/chats/data/convo.json +309 -0
- package/.shadcn-admin-ref/src/features/chats/index.tsx +349 -0
- package/.shadcn-admin-ref/src/features/dashboard/components/analytics-chart.tsx +77 -0
- package/.shadcn-admin-ref/src/features/dashboard/components/analytics.tsx +189 -0
- package/.shadcn-admin-ref/src/features/dashboard/components/overview.tsx +82 -0
- package/.shadcn-admin-ref/src/features/dashboard/components/recent-sales.tsx +83 -0
- package/.shadcn-admin-ref/src/features/dashboard/index.tsx +220 -0
- package/.shadcn-admin-ref/src/features/errors/forbidden.tsx +25 -0
- package/.shadcn-admin-ref/src/features/errors/general-error.tsx +36 -0
- package/.shadcn-admin-ref/src/features/errors/maintenance-error.tsx +19 -0
- package/.shadcn-admin-ref/src/features/errors/not-found-error.tsx +25 -0
- package/.shadcn-admin-ref/src/features/errors/unauthorized-error.tsx +25 -0
- package/.shadcn-admin-ref/src/features/settings/account/account-form.tsx +173 -0
- package/.shadcn-admin-ref/src/features/settings/account/index.tsx +14 -0
- package/.shadcn-admin-ref/src/features/settings/appearance/appearance-form.tsx +162 -0
- package/.shadcn-admin-ref/src/features/settings/appearance/index.tsx +14 -0
- package/.shadcn-admin-ref/src/features/settings/components/content-section.tsx +22 -0
- package/.shadcn-admin-ref/src/features/settings/components/sidebar-nav.tsx +84 -0
- package/.shadcn-admin-ref/src/features/settings/display/display-form.tsx +121 -0
- package/.shadcn-admin-ref/src/features/settings/display/index.tsx +13 -0
- package/.shadcn-admin-ref/src/features/settings/index.tsx +74 -0
- package/.shadcn-admin-ref/src/features/settings/notifications/index.tsx +13 -0
- package/.shadcn-admin-ref/src/features/settings/notifications/notifications-form.tsx +220 -0
- package/.shadcn-admin-ref/src/features/settings/profile/index.tsx +13 -0
- package/.shadcn-admin-ref/src/features/settings/profile/profile-form.tsx +177 -0
- package/.shadcn-admin-ref/src/features/tasks/components/data-table-bulk-actions.tsx +193 -0
- package/.shadcn-admin-ref/src/features/tasks/components/data-table-row-actions.tsx +83 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-columns.tsx +123 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-dialogs.tsx +72 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-import-dialog.tsx +110 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-multi-delete-dialog.tsx +95 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-mutate-drawer.tsx +212 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-primary-buttons.tsx +21 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-provider.tsx +36 -0
- package/.shadcn-admin-ref/src/features/tasks/components/tasks-table.tsx +197 -0
- package/.shadcn-admin-ref/src/features/tasks/data/data.tsx +77 -0
- package/.shadcn-admin-ref/src/features/tasks/data/schema.ts +13 -0
- package/.shadcn-admin-ref/src/features/tasks/data/tasks.ts +29 -0
- package/.shadcn-admin-ref/src/features/tasks/index.tsx +41 -0
- package/.shadcn-admin-ref/src/features/users/components/data-table-bulk-actions.tsx +139 -0
- package/.shadcn-admin-ref/src/features/users/components/data-table-row-actions.tsx +63 -0
- package/.shadcn-admin-ref/src/features/users/components/users-action-dialog.tsx +326 -0
- package/.shadcn-admin-ref/src/features/users/components/users-columns.tsx +138 -0
- package/.shadcn-admin-ref/src/features/users/components/users-delete-dialog.tsx +81 -0
- package/.shadcn-admin-ref/src/features/users/components/users-dialogs.tsx +51 -0
- package/.shadcn-admin-ref/src/features/users/components/users-invite-dialog.tsx +150 -0
- package/.shadcn-admin-ref/src/features/users/components/users-multi-delete-dialog.tsx +95 -0
- package/.shadcn-admin-ref/src/features/users/components/users-primary-buttons.tsx +21 -0
- package/.shadcn-admin-ref/src/features/users/components/users-provider.tsx +36 -0
- package/.shadcn-admin-ref/src/features/users/components/users-table.tsx +194 -0
- package/.shadcn-admin-ref/src/features/users/data/data.ts +35 -0
- package/.shadcn-admin-ref/src/features/users/data/schema.ts +32 -0
- package/.shadcn-admin-ref/src/features/users/data/users.ts +33 -0
- package/.shadcn-admin-ref/src/features/users/index.tsx +47 -0
- package/.shadcn-admin-ref/src/hooks/use-dialog-state.tsx +18 -0
- package/.shadcn-admin-ref/src/hooks/use-mobile.tsx +19 -0
- package/.shadcn-admin-ref/src/hooks/use-table-url-state.ts +219 -0
- package/.shadcn-admin-ref/src/lib/cookies.ts +43 -0
- package/.shadcn-admin-ref/src/lib/handle-server-error.ts +24 -0
- package/.shadcn-admin-ref/src/lib/show-submitted-data.tsx +15 -0
- package/.shadcn-admin-ref/src/lib/utils.ts +60 -0
- package/.shadcn-admin-ref/src/main.tsx +107 -0
- package/.shadcn-admin-ref/src/routeTree.gen.ts +719 -0
- package/.shadcn-admin-ref/src/routes/(auth)/forgot-password.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(auth)/otp.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(auth)/sign-in-2.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(auth)/sign-in.tsx +12 -0
- package/.shadcn-admin-ref/src/routes/(auth)/sign-up.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(errors)/401.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(errors)/403.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(errors)/404.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(errors)/500.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/(errors)/503.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/__root.tsx +30 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/apps/index.tsx +17 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/chats/index.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/errors/$error.tsx +45 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/help-center/index.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/index.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/route.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/account.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/appearance.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/display.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/index.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/notifications.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/settings/route.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/tasks/index.tsx +23 -0
- package/.shadcn-admin-ref/src/routes/_authenticated/users/index.tsx +32 -0
- package/.shadcn-admin-ref/src/routes/clerk/(auth)/route.tsx +60 -0
- package/.shadcn-admin-ref/src/routes/clerk/(auth)/sign-in.tsx +14 -0
- package/.shadcn-admin-ref/src/routes/clerk/(auth)/sign-up.tsx +9 -0
- package/.shadcn-admin-ref/src/routes/clerk/_authenticated/route.tsx +6 -0
- package/.shadcn-admin-ref/src/routes/clerk/_authenticated/user-management.tsx +184 -0
- package/.shadcn-admin-ref/src/routes/clerk/route.tsx +135 -0
- package/.shadcn-admin-ref/src/stores/auth-store.ts +53 -0
- package/.shadcn-admin-ref/src/styles/index.css +87 -0
- package/.shadcn-admin-ref/src/styles/theme.css +102 -0
- package/.shadcn-admin-ref/src/tanstack-table.d.ts +10 -0
- package/.shadcn-admin-ref/src/vite-env.d.ts +1 -0
- package/.swarm/memory.db +0 -0
- package/.swarm/memory.db-shm +0 -0
- package/.swarm/memory.db-wal +0 -0
- package/.zyflow/cli-settings.json +30 -0
- package/.zyflow/db.sqlite +0 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765491505852.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765491622627.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765491794652.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765491890392.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494002879.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494183887.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494342052.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494387244.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494387245.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765494606176.json +10 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765495967542.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765495967629.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765497861143.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765497861870.json +20 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765498021377.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765498021660.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765503255525.json +13 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765503256018.json +13 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765504009102.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765504492051.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765504946437.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1633-1765504946640.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1634-1765505950215.json +16 -0
- package/.zyflow/logs/add-gitdiagram-integration/1634-1765505950948.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1635-1765505971712.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1635-1765505971976.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1636-1765505986208.json +18 -0
- package/.zyflow/logs/add-gitdiagram-integration/1636-1765505986620.json +16 -0
- package/.zyflow/logs/integrate-claude-flow/3580-1765996816612.json +10 -0
- package/.zyflow/logs/integrate-claude-flow/3580-1766014825819.json +10 -0
- package/.zyflow/logs/integrate-claude-flow/3580-1766015183794.json +12 -0
- package/.zyflow/logs/integrate-claude-flow/3580-1766015474608.json +12 -0
- package/.zyflow/logs/integrate-claude-flow/3581-1766016502824.json +63 -0
- package/.zyflow/logs/integrate-claude-flow/3581-1766016576008.json +60 -0
- package/.zyflow/logs/integrate-claude-flow/3582-1766022737754.json +110 -0
- package/.zyflow/logs/integrate-claude-flow/3582-1766022809327.json +135 -0
- package/.zyflow/sessions.json +242 -0
- package/.zyflow/settings.json +6 -0
- package/.zyflow/tasks.db +0 -0
- package/.zyflow/tasks.db-shm +0 -0
- package/.zyflow/tasks.db-wal +0 -0
- package/.zyflow/zyflow.sqlite +0 -0
- package/Dockerfile +82 -0
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/claude-flow +34 -0
- package/components.json +21 -0
- package/config/ports.ts +28 -0
- package/docker-compose.yml +52 -0
- package/eslint.config.js +34 -0
- package/index.html +19 -0
- package/logs/mcp-error.log +55 -0
- package/logs/mcp-out.log +0 -0
- package/logs/pm2-error.log +0 -0
- package/logs/pm2-out.log +265 -0
- package/logs/py-error.log +22 -0
- package/logs/py-out.log +0 -0
- package/logs/server-error.log +11000 -0
- package/logs/server-out.log +8117 -0
- package/logs/vite-error.log +404 -0
- package/logs/vite-out.log +311 -0
- package/mcp-server/agent-tools.ts +375 -0
- package/mcp-server/cli-models.ts +193 -0
- package/mcp-server/context.ts +110 -0
- package/mcp-server/diagram-tools.ts +341 -0
- package/mcp-server/index.ts +2014 -0
- package/mcp-server/integration-tools.ts +909 -0
- package/mcp-server/moai-spec-tools.ts +416 -0
- package/mcp-server/parser.ts +422 -0
- package/mcp-server/post-task-runner.ts +253 -0
- package/mcp-server/post-task-types.ts +426 -0
- package/mcp-server/quarantine-manager.ts +479 -0
- package/mcp-server/report-generator.ts +386 -0
- package/mcp-server/task-tools.ts +619 -0
- package/mcp-server/trigger-config.ts +288 -0
- package/mcp-server/trigger-router.ts +305 -0
- package/mcp-server/triggers/event-listener.ts +331 -0
- package/mcp-server/triggers/git-hooks.ts +283 -0
- package/mcp-server/triggers/scheduler.ts +289 -0
- package/mcp-server/types.ts +55 -0
- package/memory/claude-flow@alpha-data.json +5 -0
- package/nginx/zyflow.conf +144 -0
- package/openspec/config.yaml +78 -0
- package/openspec-backup.tar.gz +0 -0
- package/package.json +154 -0
- package/packages/gitdiagram-core/.claude-flow/metrics/agent-metrics.json +1 -0
- package/packages/gitdiagram-core/.claude-flow/metrics/performance.json +87 -0
- package/packages/gitdiagram-core/.claude-flow/metrics/task-metrics.json +10 -0
- package/packages/gitdiagram-core/package.json +41 -0
- package/packages/gitdiagram-core/src/file-tree.ts +272 -0
- package/packages/gitdiagram-core/src/generator.ts +283 -0
- package/packages/gitdiagram-core/src/index.ts +78 -0
- package/packages/gitdiagram-core/src/llm-adapter.ts +235 -0
- package/packages/gitdiagram-core/src/mermaid-utils.ts +304 -0
- package/packages/gitdiagram-core/src/prompts.ts +281 -0
- package/packages/zyflow-parser/package.json +34 -0
- package/packages/zyflow-parser/src/index.ts +26 -0
- package/packages/zyflow-parser/src/moai-parser.ts +603 -0
- package/packages/zyflow-parser/src/moai-types.ts +110 -0
- package/packages/zyflow-remote-plugin/.claude-flow/metrics/agent-metrics.json +1 -0
- package/packages/zyflow-remote-plugin/.claude-flow/metrics/performance.json +87 -0
- package/packages/zyflow-remote-plugin/.claude-flow/metrics/task-metrics.json +10 -0
- package/packages/zyflow-remote-plugin/package.json +31 -0
- package/packages/zyflow-remote-plugin/src/index.ts +71 -0
- package/packages/zyflow-remote-plugin/src/remote-config.ts +232 -0
- package/packages/zyflow-remote-plugin/src/router.ts +535 -0
- package/packages/zyflow-remote-plugin/src/ssh-config-parser.ts +123 -0
- package/packages/zyflow-remote-plugin/src/ssh-manager.ts +598 -0
- package/packages/zyflow-remote-plugin/src/types.ts +149 -0
- package/plugin/manifest.json +26 -0
- package/plugin/package.json +13 -0
- package/public/favicon.svg +4 -0
- package/server/adk/agents/error-analyzer.ts +223 -0
- package/server/adk/agents/fix-generator.ts +187 -0
- package/server/adk/agents/pr-agent.ts +264 -0
- package/server/adk/agents/validator.ts +187 -0
- package/server/adk/config.ts +43 -0
- package/server/adk/index.ts +69 -0
- package/server/adk/integration.ts +297 -0
- package/server/adk/orchestrator.ts +405 -0
- package/server/adk/tools/build-tools.ts +290 -0
- package/server/adk/tools/file-tools.ts +351 -0
- package/server/adk/tools/git-tools.ts +280 -0
- package/server/adk/tools/github-tools.ts +249 -0
- package/server/agents/agent-monitor.ts +416 -0
- package/server/agents/alert-integration.ts +312 -0
- package/server/agents/error-analyzer.ts +472 -0
- package/server/agents/error-detector.ts +442 -0
- package/server/agents/fix-generator.ts +421 -0
- package/server/agents/fix-validator.ts +428 -0
- package/server/agents/merge-policy.ts +362 -0
- package/server/agents/pr-workflow.ts +476 -0
- package/server/agents/prompts/error-analysis.ts +393 -0
- package/server/ai/gemini-client.ts +499 -0
- package/server/ai/index.ts +317 -0
- package/server/ai/types.ts +137 -0
- package/server/app.ts +3693 -0
- package/server/archive-manager.ts +604 -0
- package/server/backlog/index.ts +7 -0
- package/server/backlog/migration.ts +331 -0
- package/server/backlog/parser.ts +323 -0
- package/server/backlog/sync.ts +325 -0
- package/server/change-log.ts +868 -0
- package/server/claude-flow/index.ts +12 -0
- package/server/claude-flow/prompt-builder.ts +407 -0
- package/server/claude-flow/types.ts +33 -0
- package/server/cli-adapter/index.ts +11 -0
- package/server/cli-adapter/process-manager.ts +612 -0
- package/server/cli-adapter/profile-manager.ts +286 -0
- package/server/cli-adapter/routes.ts +561 -0
- package/server/cli-adapter/types.ts +226 -0
- package/server/config.d.ts +18 -0
- package/server/config.js +79 -0
- package/server/config.ts +262 -0
- package/server/flow-sync.ts +543 -0
- package/server/git/change-workflow.ts +446 -0
- package/server/git/commands.ts +370 -0
- package/server/git/github.ts +247 -0
- package/server/git/index.ts +1202 -0
- package/server/git/status.ts +322 -0
- package/server/index.ts +136 -0
- package/server/integrations/crypto.ts +142 -0
- package/server/integrations/db/client.ts +169 -0
- package/server/integrations/db/schema.ts +167 -0
- package/server/integrations/env-parser.ts +365 -0
- package/server/integrations/index.ts +101 -0
- package/server/integrations/keychain.ts +239 -0
- package/server/integrations/local/file-utils.ts +383 -0
- package/server/integrations/local/index.ts +64 -0
- package/server/integrations/local/resolver.ts +439 -0
- package/server/integrations/local/types.ts +122 -0
- package/server/integrations/routes.ts +1100 -0
- package/server/integrations/service-patterns.ts +771 -0
- package/server/integrations/services/accounts.ts +356 -0
- package/server/integrations/services/env-import.ts +279 -0
- package/server/integrations/services/projects.ts +552 -0
- package/server/integrations/services/system-import.ts +1110 -0
- package/server/migrations/ears-generator.ts +491 -0
- package/server/migrations/gherkin-generator.ts +605 -0
- package/server/migrations/index.ts +73 -0
- package/server/migrations/migrate-spec-format.ts +492 -0
- package/server/migrations/openspec-parser.ts +542 -0
- package/server/migrations/tag-generator.ts +474 -0
- package/server/moai-specs.ts +487 -0
- package/server/moai-watcher.ts +145 -0
- package/server/parser-debug.ts +37 -0
- package/server/parser-utils.ts +316 -0
- package/server/parser.d.ts +17 -0
- package/server/parser.js +221 -0
- package/server/parser.ts +342 -0
- package/server/remote-watcher.ts +367 -0
- package/server/replay-engine.ts +915 -0
- package/server/routes/alerts.ts +1028 -0
- package/server/routes/changes.ts +812 -0
- package/server/routes/docs.ts +898 -0
- package/server/routes/flow.ts +2814 -0
- package/server/routes/global-chat.ts +162 -0
- package/server/routes/leann.ts +327 -0
- package/server/routes/projects.ts +1282 -0
- package/server/routes/search.ts +266 -0
- package/server/routes/specs.ts +482 -0
- package/server/routes/webhooks.ts +579 -0
- package/server/server/parser.js +265 -0
- package/server/services/githubActionsPoller.ts +797 -0
- package/server/services/slackNotifier.ts +476 -0
- package/server/src/types/index.js +1 -0
- package/server/sync-tasks.ts +741 -0
- package/server/tasks/cli/commands.ts +269 -0
- package/server/tasks/cli/index.ts +152 -0
- package/server/tasks/core/search.ts +81 -0
- package/server/tasks/core/task.ts +307 -0
- package/server/tasks/db/client.ts +1008 -0
- package/server/tasks/db/schema.ts +572 -0
- package/server/tasks/index.ts +24 -0
- package/server/tasks.db +0 -0
- package/server/types/archive.ts +136 -0
- package/server/types/change-log.ts +643 -0
- package/server/types/spec.ts +188 -0
- package/server/unified-spec-scanner.ts +753 -0
- package/server/utils/crypto.ts +179 -0
- package/server/utils/webhook-verify.ts +216 -0
- package/server/watcher.ts +132 -0
- package/server/websocket.ts +99 -0
- package/server-output.log +6 -0
- package/sonar-project.properties +18 -0
- package/src/App.tsx +386 -0
- package/src/api/client.ts +346 -0
- package/src/api/error-interceptor.ts +366 -0
- package/src/api/errors.ts +123 -0
- package/src/api/flow.ts +233 -0
- package/src/api/offline-queue.ts +351 -0
- package/src/api/retry-logic.ts +233 -0
- package/src/components/OfflineModeBanner.tsx +159 -0
- package/src/components/SSEStatusIndicator.tsx +194 -0
- package/src/components/agent/AgentChat.tsx +243 -0
- package/src/components/agent/AgentPage.tsx +182 -0
- package/src/components/agent/AgentSidebar.tsx +231 -0
- package/src/components/agent/index.ts +7 -0
- package/src/components/alerts/AlertCenter.tsx +239 -0
- package/src/components/alerts/AlertDashboard.tsx +211 -0
- package/src/components/alerts/AlertDetail.tsx +474 -0
- package/src/components/alerts/AlertList.tsx +113 -0
- package/src/components/alerts/AlertSettings.tsx +336 -0
- package/src/components/alerts/index.ts +5 -0
- package/src/components/chat/ChatPanel.tsx +642 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/cli/AddCustomCLIDialog.tsx +210 -0
- package/src/components/cli/CLISelector.tsx +187 -0
- package/src/components/cli/index.ts +8 -0
- package/src/components/dashboard/ArchivedChangeList.tsx +102 -0
- package/src/components/dashboard/ArchivedChangeViewer.tsx +184 -0
- package/src/components/dashboard/ArchivedChangesPage.tsx +31 -0
- package/src/components/dashboard/ChangeList.tsx +86 -0
- package/src/components/dashboard/ThemeToggle.tsx +33 -0
- package/src/components/diagram/DiagramViewer.tsx +256 -0
- package/src/components/diagram/MermaidRenderer.tsx +163 -0
- package/src/components/diagram/ProjectDiagramTab.tsx +161 -0
- package/src/components/diagram/index.ts +13 -0
- package/src/components/errors/ErrorBoundary.tsx +276 -0
- package/src/components/errors/ErrorFallback.tsx +198 -0
- package/src/components/errors/ErrorToast.tsx +221 -0
- package/src/components/flow/BacklogView.tsx +1142 -0
- package/src/components/flow/ChangeDetail.tsx +475 -0
- package/src/components/flow/ChangeItem.tsx +230 -0
- package/src/components/flow/ChangeList.tsx +92 -0
- package/src/components/flow/ExecutionHistoryDialog.tsx +224 -0
- package/src/components/flow/FlowContent.tsx +212 -0
- package/src/components/flow/FlowPage.tsx +9 -0
- package/src/components/flow/PipelineBar.tsx +214 -0
- package/src/components/flow/ProjectDashboard.tsx +222 -0
- package/src/components/flow/SpecDetail.tsx +138 -0
- package/src/components/flow/SpecDetailTabs.tsx +176 -0
- package/src/components/flow/SpecItem.tsx +93 -0
- package/src/components/flow/SpecProgressBar.tsx +47 -0
- package/src/components/flow/StageContent.tsx +620 -0
- package/src/components/flow/StandaloneTasks.tsx +960 -0
- package/src/components/flow/TaskExecutionDialog.tsx +1204 -0
- package/src/components/flow/index.ts +9 -0
- package/src/components/flow/task-execution/AgentSlider.tsx +37 -0
- package/src/components/flow/task-execution/ConsensusSettings.tsx +129 -0
- package/src/components/flow/task-execution/ExecutionOutput.tsx +398 -0
- package/src/components/flow/task-execution/ModelSelector.tsx +134 -0
- package/src/components/flow/task-execution/ProviderSelector.tsx +137 -0
- package/src/components/flow/task-execution/RecommendationBanner.tsx +71 -0
- package/src/components/flow/task-execution/StatusBadge.tsx +43 -0
- package/src/components/flow/task-execution/StrategySelector.tsx +48 -0
- package/src/components/flow/task-execution/SwarmSummary.tsx +55 -0
- package/src/components/flow/task-execution/index.ts +14 -0
- package/src/components/flow/task-execution/types.ts +56 -0
- package/src/components/git/ChangeWorkflowDialog.tsx +582 -0
- package/src/components/git/ConflictResolutionDialog.tsx +398 -0
- package/src/components/git/GitBranchSelector.tsx +212 -0
- package/src/components/git/GitCommitDialog.tsx +254 -0
- package/src/components/git/GitStatusBadge.tsx +148 -0
- package/src/components/git/GitSyncButton.tsx +128 -0
- package/src/components/git/RemoteStatusBanner.tsx +143 -0
- package/src/components/git/index.ts +9 -0
- package/src/components/integrations/EnvImportDialog.tsx +524 -0
- package/src/components/integrations/EnvironmentDialog.tsx +227 -0
- package/src/components/integrations/IntegrationBadges.tsx +91 -0
- package/src/components/integrations/IntegrationsSettings.tsx +55 -0
- package/src/components/integrations/ProjectIntegrations.tsx +481 -0
- package/src/components/integrations/ServiceAccountDialog.tsx +422 -0
- package/src/components/integrations/ServiceAccountList.tsx +305 -0
- package/src/components/integrations/SystemImportDialog.tsx +436 -0
- package/src/components/integrations/TestAccountDialog.tsx +162 -0
- package/src/components/integrations/index.ts +6 -0
- package/src/components/layout/AppSidebar.tsx +284 -0
- package/src/components/layout/FlowSidebar.tsx +435 -0
- package/src/components/layout/GlobalCommandPalette.tsx +410 -0
- package/src/components/layout/MenuBar.tsx +227 -0
- package/src/components/layout/StatusBar.tsx +226 -0
- package/src/components/monitoring/ErrorDashboard.tsx +274 -0
- package/src/components/monitoring/ErrorDetailPanel.tsx +200 -0
- package/src/components/monitoring/ErrorFilters.tsx +219 -0
- package/src/components/monitoring/ErrorHistoryList.tsx +141 -0
- package/src/components/monitoring/ErrorStats.tsx +249 -0
- package/src/components/remote/RemoteFileBrowser.tsx +249 -0
- package/src/components/remote/RemoteServerDialog.tsx +234 -0
- package/src/components/remote/RemoteServerList.tsx +366 -0
- package/src/components/remote/index.ts +7 -0
- package/src/components/settings/CLISettings.tsx +522 -0
- package/src/components/settings/CustomCLIDialog.tsx +548 -0
- package/src/components/settings/IntegrationsSettings.tsx +51 -0
- package/src/components/settings/ProjectSettings.tsx +441 -0
- package/src/components/settings/ProjectsSettings.tsx +541 -0
- package/src/components/settings/SearchSettings.tsx +272 -0
- package/src/components/settings/SettingsPage.tsx +68 -0
- package/src/components/settings/index.ts +5 -0
- package/src/components/swarm/ExecutionPanel.tsx +284 -0
- package/src/components/swarm/LogViewer.tsx +196 -0
- package/src/components/swarm/ProgressIndicator.tsx +111 -0
- package/src/components/swarm/index.ts +3 -0
- package/src/components/tasks/ArchiveTable.tsx +203 -0
- package/src/components/tasks/KanbanBoard.tsx +264 -0
- package/src/components/tasks/TaskCard.tsx +138 -0
- package/src/components/tasks/TaskColumn.tsx +81 -0
- package/src/components/tasks/TaskDialog.tsx +274 -0
- package/src/components/tasks/index.ts +5 -0
- package/src/components/tasks/types.ts +43 -0
- package/src/components/ui/alert-dialog.tsx +154 -0
- package/src/components/ui/alert.tsx +65 -0
- package/src/components/ui/badge.tsx +45 -0
- package/src/components/ui/button.tsx +58 -0
- package/src/components/ui/card.tsx +91 -0
- package/src/components/ui/checkbox.tsx +29 -0
- package/src/components/ui/collapsible.tsx +31 -0
- package/src/components/ui/command.tsx +184 -0
- package/src/components/ui/confirm-dialog.tsx +55 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +254 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/label.tsx +22 -0
- package/src/components/ui/markdown.tsx +100 -0
- package/src/components/ui/progress.tsx +27 -0
- package/src/components/ui/resizable-sidebar.tsx +156 -0
- package/src/components/ui/resizable.tsx +54 -0
- package/src/components/ui/right-resizable-sidebar.tsx +158 -0
- package/src/components/ui/scroll-area.tsx +64 -0
- package/src/components/ui/select.tsx +185 -0
- package/src/components/ui/separator.tsx +25 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +726 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +56 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/table.tsx +113 -0
- package/src/components/ui/tabs.tsx +63 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/tooltip.tsx +60 -0
- package/src/config/api.ts +83 -0
- package/src/constants/error-codes.ts +255 -0
- package/src/constants/stages.ts +27 -0
- package/src/context/ErrorContext.tsx +185 -0
- package/src/context/theme-provider.tsx +63 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/useAI.ts +206 -0
- package/src/hooks/useAgentSession.ts +431 -0
- package/src/hooks/useAlerts.ts +935 -0
- package/src/hooks/useArchivedChanges.ts +39 -0
- package/src/hooks/useAsyncError.ts +45 -0
- package/src/hooks/useChangeGit.ts +727 -0
- package/src/hooks/useChanges.ts +20 -0
- package/src/hooks/useClaude.ts +130 -0
- package/src/hooks/useDocs.ts +182 -0
- package/src/hooks/useErrorDashboard.ts +243 -0
- package/src/hooks/useErrorHandler.ts +150 -0
- package/src/hooks/useExecutionHistory.ts +55 -0
- package/src/hooks/useFlowChanges.ts +850 -0
- package/src/hooks/useFlowItems.ts +205 -0
- package/src/hooks/useGit.ts +427 -0
- package/src/hooks/useHideCompletedSpecs.ts +15 -0
- package/src/hooks/useInstance.ts +40 -0
- package/src/hooks/useIntegrations.ts +737 -0
- package/src/hooks/useLeannStatus.ts +93 -0
- package/src/hooks/useNetworkStatus.ts +167 -0
- package/src/hooks/useProjects.ts +353 -0
- package/src/hooks/useRemoteServers.ts +383 -0
- package/src/hooks/useSSEConnection.ts +346 -0
- package/src/hooks/useSpecs.ts +39 -0
- package/src/hooks/useSwarm.ts +462 -0
- package/src/hooks/useTasks.ts +137 -0
- package/src/hooks/useURLSync.ts +122 -0
- package/src/hooks/useWebSocket.ts +262 -0
- package/src/lib/utils.ts +121 -0
- package/src/main.tsx +22 -0
- package/src/stores/errorStore.ts +301 -0
- package/src/stores/offlineStore.ts +266 -0
- package/src/stores/sseStore.ts +247 -0
- package/src/stores/useHideCompletedStore.ts +21 -0
- package/src/styles/index.css +87 -0
- package/src/styles/theme.css +102 -0
- package/src/types/ai.ts +191 -0
- package/src/types/errors.ts +253 -0
- package/src/types/flow.ts +382 -0
- package/src/types/index.ts +614 -0
- package/src/utils/error-logger.ts +399 -0
- package/src/utils/error-statistics.ts +305 -0
- package/src/utils/logger.ts +280 -0
- package/src/utils/task-routing.ts +795 -0
- package/src/vite-env.d.ts +1 -0
- package/test-results/.last-run.json +4 -0
- package/tmp/check-docker-final.ts +48 -0
- package/tmp/check-docker-tasks.ts +58 -0
- package/tmp/check-docker-tasks2.ts +48 -0
- package/tmp/check-docker-tasks3.ts +42 -0
- package/tmp/check-mobile-tasks.ts +57 -0
- package/tmp/check-zywiki-tasks.ts +49 -0
- package/tmp/sync-mobile.ts +11 -0
- package/tmp/sync-zywiki.ts +68 -0
- package/tmp/test-docker-parser.ts +15 -0
- package/tmp/test-mobile-parser.ts +28 -0
- package/tmp/test-parser.ts +27 -0
- package/tmp/test-unnumbered.ts +35 -0
- package/zyflow.db +0 -0
|
@@ -0,0 +1,2814 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow Router
|
|
3
|
+
*
|
|
4
|
+
* Flow Changes 및 Tasks 관련 API 라우터 (DB 기반)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Router } from 'express'
|
|
8
|
+
import { readdir, readFile, writeFile, access, unlink, rename, stat } from 'fs/promises'
|
|
9
|
+
import { existsSync } from 'fs'
|
|
10
|
+
import { join, basename } from 'path'
|
|
11
|
+
import { exec } from 'child_process'
|
|
12
|
+
import { promisify } from 'util'
|
|
13
|
+
import { loadConfig, getActiveProject, getProjectById } from '../config.js'
|
|
14
|
+
import { parseTasksFile } from '../parser.js'
|
|
15
|
+
import { parsePlanFile, parseAcceptanceFile } from '@zyflow/parser'
|
|
16
|
+
import { initDb } from '../tasks/index.js'
|
|
17
|
+
import { getSqlite } from '../tasks/db/client.js'
|
|
18
|
+
import type { Stage, ChangeStatus, TaskOrigin } from '../tasks/db/schema.js'
|
|
19
|
+
import { emit } from '../websocket.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count markdown checklists in content for progress calculation.
|
|
23
|
+
* Used as fallback when no TAG-XXX format tags are found.
|
|
24
|
+
*/
|
|
25
|
+
function countMarkdownChecklists(content: string): { completed: number; total: number; percentage: number } | null {
|
|
26
|
+
const checklistRegex = /^[\s]*-\s*\[([ xX])\]/gm
|
|
27
|
+
let completed = 0
|
|
28
|
+
let total = 0
|
|
29
|
+
|
|
30
|
+
let match
|
|
31
|
+
while ((match = checklistRegex.exec(content)) !== null) {
|
|
32
|
+
total++
|
|
33
|
+
if (match[1].toLowerCase() === 'x') {
|
|
34
|
+
completed++
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (total === 0) return null
|
|
39
|
+
return { completed, total, percentage: Math.round((completed / total) * 100) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Remote plugin is optional - only load if installed
|
|
43
|
+
interface RemotePlugin {
|
|
44
|
+
getRemoteServerById: (id: string) => Promise<unknown>
|
|
45
|
+
listDirectory: (server: unknown, path: string) => Promise<{ entries: Array<{ type: string; name: string }> }>
|
|
46
|
+
readRemoteFile: (server: unknown, path: string) => Promise<string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let remotePlugin: RemotePlugin | null = null
|
|
50
|
+
|
|
51
|
+
async function getRemotePlugin(): Promise<RemotePlugin | null> {
|
|
52
|
+
if (remotePlugin) return remotePlugin
|
|
53
|
+
try {
|
|
54
|
+
const mod = await import('@zyflow/remote-plugin')
|
|
55
|
+
remotePlugin = mod as unknown as RemotePlugin
|
|
56
|
+
return remotePlugin
|
|
57
|
+
} catch {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
import {
|
|
63
|
+
syncBacklogToDb,
|
|
64
|
+
saveTaskToBacklogFile,
|
|
65
|
+
generateNewBacklogTaskId,
|
|
66
|
+
getBacklogPath,
|
|
67
|
+
ensureBacklogDir,
|
|
68
|
+
type BacklogTask,
|
|
69
|
+
// Migration
|
|
70
|
+
previewMigration,
|
|
71
|
+
migrateInboxToBacklog,
|
|
72
|
+
migrateSelectedInboxTasks,
|
|
73
|
+
} from '../backlog/index.js'
|
|
74
|
+
import { serializeBacklogTask, generateBacklogFilename } from '../backlog/parser.js'
|
|
75
|
+
import { syncChangeTasksFromFile, syncChangeTasksForProject, syncRemoteChangeTasksForProject } from '../sync-tasks.js'
|
|
76
|
+
import { scanMoaiSpecs as syncMoaiSpecsToDb } from '../flow-sync.js'
|
|
77
|
+
import { getMoaiSpec, scanMoaiSpecs, scanRemoteMoaiSpecs } from '../moai-specs.js'
|
|
78
|
+
|
|
79
|
+
const execAsync = promisify(exec)
|
|
80
|
+
|
|
81
|
+
export const flowRouter = Router()
|
|
82
|
+
|
|
83
|
+
// Stage order for progress calculation
|
|
84
|
+
const STAGES: Stage[] = ['spec', 'changes', 'task', 'code', 'test', 'commit', 'docs']
|
|
85
|
+
|
|
86
|
+
// Initialize task database
|
|
87
|
+
async function initTaskDb() {
|
|
88
|
+
initDb()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Helper to get paths for active project
|
|
92
|
+
async function getProjectPaths() {
|
|
93
|
+
const project = await getActiveProject()
|
|
94
|
+
if (!project) {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
projectPath: project.path,
|
|
99
|
+
openspecDir: join(project.path, 'openspec', 'changes'),
|
|
100
|
+
specsDir: join(project.path, 'openspec', 'specs'),
|
|
101
|
+
plansDir: join(project.path, '.zyflow', 'plans'),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Helper to get project info for a specific change (supports remote projects)
|
|
106
|
+
async function getProjectForChange(changeId: string) {
|
|
107
|
+
initDb()
|
|
108
|
+
const config = await loadConfig()
|
|
109
|
+
const sqlite = getSqlite()
|
|
110
|
+
|
|
111
|
+
// Find the change in database to get project_id
|
|
112
|
+
const change = sqlite
|
|
113
|
+
.prepare('SELECT project_id FROM changes WHERE id = ?')
|
|
114
|
+
.get(changeId) as { project_id: string } | undefined
|
|
115
|
+
|
|
116
|
+
if (!change) return null
|
|
117
|
+
|
|
118
|
+
// Find the project from config
|
|
119
|
+
const project = config.projects.find((p) => p.id === change.project_id)
|
|
120
|
+
return project || null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Helper to find archive folder path for a change (remote projects use date-prefixed folders)
|
|
124
|
+
async function findRemoteArchivePath(
|
|
125
|
+
plugin: RemotePlugin,
|
|
126
|
+
server: unknown,
|
|
127
|
+
archiveDir: string,
|
|
128
|
+
changeId: string
|
|
129
|
+
): Promise<string | null> {
|
|
130
|
+
try {
|
|
131
|
+
const result = await plugin.listDirectory(server, archiveDir)
|
|
132
|
+
// Archive folders are named like "2026-01-17-change-id" or just "change-id"
|
|
133
|
+
const matchingFolder = result.entries.find(
|
|
134
|
+
(entry) => entry.type === 'directory' && (entry.name === changeId || entry.name.endsWith(`-${changeId}`))
|
|
135
|
+
)
|
|
136
|
+
return matchingFolder ? `${archiveDir}/${matchingFolder.name}` : null
|
|
137
|
+
} catch {
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Helper: Get stages for a change
|
|
143
|
+
function getChangeStages(changeId: string, projectId?: string) {
|
|
144
|
+
const sqlite = getSqlite()
|
|
145
|
+
const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
|
|
146
|
+
spec: { total: 0, completed: 0, tasks: [] },
|
|
147
|
+
changes: { total: 0, completed: 0, tasks: [] },
|
|
148
|
+
task: { total: 0, completed: 0, tasks: [] },
|
|
149
|
+
code: { total: 0, completed: 0, tasks: [] },
|
|
150
|
+
test: { total: 0, completed: 0, tasks: [] },
|
|
151
|
+
commit: { total: 0, completed: 0, tasks: [] },
|
|
152
|
+
docs: { total: 0, completed: 0, tasks: [] },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// project_id가 있으면 함께 조건 추가
|
|
156
|
+
const sql = projectId
|
|
157
|
+
? `SELECT * FROM tasks WHERE change_id = ? AND project_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
|
|
158
|
+
: `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
|
|
159
|
+
const params = projectId ? [changeId, projectId] : [changeId]
|
|
160
|
+
|
|
161
|
+
let tasks = sqlite.prepare(sql).all(...params) as Array<{
|
|
162
|
+
id: number
|
|
163
|
+
change_id: string
|
|
164
|
+
stage: Stage
|
|
165
|
+
title: string
|
|
166
|
+
description: string | null
|
|
167
|
+
status: string
|
|
168
|
+
priority: string
|
|
169
|
+
tags: string | null
|
|
170
|
+
assignee: string | null
|
|
171
|
+
order: number
|
|
172
|
+
group_title: string | null
|
|
173
|
+
group_order: number
|
|
174
|
+
task_order: number
|
|
175
|
+
major_title: string | null
|
|
176
|
+
sub_order: number | null
|
|
177
|
+
display_id: string | null
|
|
178
|
+
created_at: number
|
|
179
|
+
updated_at: number
|
|
180
|
+
archived_at: number | null
|
|
181
|
+
}>
|
|
182
|
+
|
|
183
|
+
// Fallback: project_id로 필터링했는데 결과가 없으면, project_id 없이 재시도 (레거시 데이터 지원)
|
|
184
|
+
if (tasks.length === 0 && projectId) {
|
|
185
|
+
const fallbackSql = `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
|
|
186
|
+
tasks = sqlite.prepare(fallbackSql).all(changeId) as typeof tasks
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const task of tasks) {
|
|
190
|
+
const stage = task.stage as Stage
|
|
191
|
+
stages[stage].total++
|
|
192
|
+
if (task.status === 'done') {
|
|
193
|
+
stages[stage].completed++
|
|
194
|
+
}
|
|
195
|
+
stages[stage].tasks.push({
|
|
196
|
+
id: task.id,
|
|
197
|
+
changeId: task.change_id,
|
|
198
|
+
stage: task.stage,
|
|
199
|
+
title: task.title,
|
|
200
|
+
description: task.description,
|
|
201
|
+
status: task.status,
|
|
202
|
+
priority: task.priority,
|
|
203
|
+
tags: task.tags ? JSON.parse(task.tags) : [],
|
|
204
|
+
assignee: task.assignee,
|
|
205
|
+
order: task.order,
|
|
206
|
+
groupTitle: task.group_title,
|
|
207
|
+
groupOrder: task.group_order,
|
|
208
|
+
taskOrder: task.task_order,
|
|
209
|
+
majorTitle: task.major_title,
|
|
210
|
+
subOrder: task.sub_order,
|
|
211
|
+
displayId: task.display_id,
|
|
212
|
+
createdAt: new Date(task.created_at).toISOString(),
|
|
213
|
+
updatedAt: new Date(task.updated_at).toISOString(),
|
|
214
|
+
archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return stages
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Helper: Calculate progress
|
|
222
|
+
function calculateProgress(stages: Record<Stage, { total: number; completed: number }>): number {
|
|
223
|
+
let totalTasks = 0
|
|
224
|
+
let completedTasks = 0
|
|
225
|
+
for (const stage of STAGES) {
|
|
226
|
+
totalTasks += stages[stage].total
|
|
227
|
+
completedTasks += stages[stage].completed
|
|
228
|
+
}
|
|
229
|
+
return totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Helper: Determine current stage
|
|
233
|
+
function determineCurrentStage(stages: Record<Stage, { total: number; completed: number }>): Stage {
|
|
234
|
+
for (const stage of STAGES) {
|
|
235
|
+
if (stages[stage].total > stages[stage].completed) {
|
|
236
|
+
return stage
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return 'docs'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Helper: Get stages for a remote change via SSH
|
|
243
|
+
async function getRemoteChangeStages(
|
|
244
|
+
changeId: string,
|
|
245
|
+
project: { path: string; remote: { serverId: string } }
|
|
246
|
+
): Promise<Record<Stage, { total: number; completed: number; tasks: unknown[] }>> {
|
|
247
|
+
const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
|
|
248
|
+
spec: { total: 0, completed: 0, tasks: [] },
|
|
249
|
+
changes: { total: 0, completed: 0, tasks: [] },
|
|
250
|
+
task: { total: 0, completed: 0, tasks: [] },
|
|
251
|
+
code: { total: 0, completed: 0, tasks: [] },
|
|
252
|
+
test: { total: 0, completed: 0, tasks: [] },
|
|
253
|
+
commit: { total: 0, completed: 0, tasks: [] },
|
|
254
|
+
docs: { total: 0, completed: 0, tasks: [] },
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const plugin = await import('@zyflow/remote-plugin')
|
|
259
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
260
|
+
if (!server) return stages
|
|
261
|
+
|
|
262
|
+
const tasksPath = `${project.path}/openspec/changes/${changeId}/tasks.md`
|
|
263
|
+
const tasksContent = await plugin.readRemoteFile(server, tasksPath)
|
|
264
|
+
|
|
265
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
266
|
+
|
|
267
|
+
// ExtendedTaskGroup 타입으로 캐스팅하여 확장 필드 접근
|
|
268
|
+
interface ExtendedGroup {
|
|
269
|
+
title: string
|
|
270
|
+
tasks: Array<{
|
|
271
|
+
id: string
|
|
272
|
+
title: string
|
|
273
|
+
completed: boolean
|
|
274
|
+
lineNumber: number
|
|
275
|
+
displayId?: string
|
|
276
|
+
}>
|
|
277
|
+
majorOrder?: number
|
|
278
|
+
majorTitle?: string
|
|
279
|
+
subOrder?: number
|
|
280
|
+
groupTitle?: string
|
|
281
|
+
groupOrder?: number
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// tasks.md의 모든 태스크는 'task' stage에 매핑
|
|
285
|
+
for (const group of parsed.groups as ExtendedGroup[]) {
|
|
286
|
+
const majorOrder = group.majorOrder ?? 1
|
|
287
|
+
const majorTitle = group.majorTitle ?? group.title
|
|
288
|
+
const subOrder = group.subOrder ?? 1
|
|
289
|
+
const groupTitle = group.groupTitle ?? group.title
|
|
290
|
+
const groupOrder = group.groupOrder ?? majorOrder
|
|
291
|
+
|
|
292
|
+
for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
|
|
293
|
+
const task = group.tasks[taskIdx]
|
|
294
|
+
const taskOrder = taskIdx + 1
|
|
295
|
+
|
|
296
|
+
stages.task.total++
|
|
297
|
+
if (task.completed) {
|
|
298
|
+
stages.task.completed++
|
|
299
|
+
}
|
|
300
|
+
stages.task.tasks.push({
|
|
301
|
+
id: task.id,
|
|
302
|
+
changeId,
|
|
303
|
+
stage: 'task',
|
|
304
|
+
title: task.title,
|
|
305
|
+
description: null,
|
|
306
|
+
status: task.completed ? 'done' : 'todo',
|
|
307
|
+
priority: 'medium',
|
|
308
|
+
tags: [],
|
|
309
|
+
assignee: null,
|
|
310
|
+
order: taskOrder,
|
|
311
|
+
groupTitle,
|
|
312
|
+
groupOrder,
|
|
313
|
+
taskOrder,
|
|
314
|
+
majorTitle,
|
|
315
|
+
subOrder,
|
|
316
|
+
displayId: task.displayId || null,
|
|
317
|
+
createdAt: new Date().toISOString(),
|
|
318
|
+
updatedAt: new Date().toISOString(),
|
|
319
|
+
archivedAt: null,
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (error) {
|
|
324
|
+
// "No such file" is expected for archived changes or missing tasks.md - skip silently
|
|
325
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
326
|
+
const errorCode = (error as { code?: number })?.code
|
|
327
|
+
if (errorCode !== 2 && !errorMessage.includes('No such file')) {
|
|
328
|
+
console.error('Failed to get remote change stages:', error)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return stages
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// =============================================
|
|
336
|
+
// MoAI SPEC Support Functions (TAG-005)
|
|
337
|
+
// =============================================
|
|
338
|
+
|
|
339
|
+
// Helper: Check if ID is MoAI SPEC format (SPEC-XXX)
|
|
340
|
+
function isMoaiSpecId(id: string): boolean {
|
|
341
|
+
return id.startsWith('SPEC-')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Helper: Calculate TAG progress from plan.md
|
|
345
|
+
async function calculateTagProgress(
|
|
346
|
+
specId: string,
|
|
347
|
+
projectPath: string
|
|
348
|
+
): Promise<{ completed: number; total: number; percentage: number } | null> {
|
|
349
|
+
try {
|
|
350
|
+
const planPath = join(projectPath, '.moai', 'specs', specId, 'plan.md')
|
|
351
|
+
if (!existsSync(planPath)) {
|
|
352
|
+
return null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const content = await readFile(planPath, 'utf-8')
|
|
356
|
+
const parsed = parsePlanFile(content)
|
|
357
|
+
|
|
358
|
+
const completed = parsed.tags.filter((t) => t.completed).length
|
|
359
|
+
const total = parsed.tags.length
|
|
360
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
|
|
361
|
+
|
|
362
|
+
return { completed, total, percentage }
|
|
363
|
+
} catch {
|
|
364
|
+
return null
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Helper: Get MoAI SPEC detail with spec.md, plan.md, acceptance.md content
|
|
369
|
+
async function getMoaiSpecDetail(
|
|
370
|
+
specId: string,
|
|
371
|
+
projectId?: string
|
|
372
|
+
): Promise<{
|
|
373
|
+
id: string
|
|
374
|
+
title: string
|
|
375
|
+
type: 'spec'
|
|
376
|
+
status: string
|
|
377
|
+
currentStage: Stage
|
|
378
|
+
progress: number
|
|
379
|
+
createdAt: string
|
|
380
|
+
updatedAt: string
|
|
381
|
+
spec: { content: string | null; title?: string } | null
|
|
382
|
+
plan: { content: string | null; tags: unknown[] | null; progress: { completed: number; total: number; percentage: number } | null } | null
|
|
383
|
+
acceptance: { content: string | null; criteria: unknown[] | null } | null
|
|
384
|
+
stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }>
|
|
385
|
+
} | null> {
|
|
386
|
+
try {
|
|
387
|
+
const sqlite = getSqlite()
|
|
388
|
+
|
|
389
|
+
// Get change record from DB
|
|
390
|
+
let change
|
|
391
|
+
if (projectId) {
|
|
392
|
+
change = sqlite
|
|
393
|
+
.prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
|
|
394
|
+
.get(specId, projectId) as {
|
|
395
|
+
id: string
|
|
396
|
+
project_id: string
|
|
397
|
+
title: string
|
|
398
|
+
spec_path: string | null
|
|
399
|
+
status: ChangeStatus
|
|
400
|
+
current_stage: Stage
|
|
401
|
+
progress: number
|
|
402
|
+
created_at: number
|
|
403
|
+
updated_at: number
|
|
404
|
+
} | undefined
|
|
405
|
+
} else {
|
|
406
|
+
change = sqlite
|
|
407
|
+
.prepare(`SELECT * FROM changes WHERE id = ?`)
|
|
408
|
+
.get(specId) as typeof change
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Get project path from config
|
|
412
|
+
const config = await loadConfig()
|
|
413
|
+
let project
|
|
414
|
+
let projectPath: string
|
|
415
|
+
|
|
416
|
+
if (change) {
|
|
417
|
+
// DB record exists - use its project_id
|
|
418
|
+
project = config.projects.find((p) => p.id === change.project_id)
|
|
419
|
+
if (!project) {
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
422
|
+
projectPath = project.path
|
|
423
|
+
|
|
424
|
+
// For remote projects, read spec.md to get current status (DB may be stale)
|
|
425
|
+
if (project.remote) {
|
|
426
|
+
const plugin = await getRemotePlugin()
|
|
427
|
+
if (plugin) {
|
|
428
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
429
|
+
if (server) {
|
|
430
|
+
const remoteSpecPath = `${projectPath}/.moai/specs/${specId}/spec.md`
|
|
431
|
+
try {
|
|
432
|
+
const specContent = await plugin.readRemoteFile(server, remoteSpecPath)
|
|
433
|
+
// Extract status from frontmatter
|
|
434
|
+
const statusMatch = specContent.match(/^status:\s+(.+)$/m)
|
|
435
|
+
if (statusMatch) {
|
|
436
|
+
const specStatus = statusMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
437
|
+
const isComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
|
|
438
|
+
const mappedStatus = (isComplete ? 'done' : specStatus === 'active' ? 'in_progress' : 'pending') as ChangeStatus
|
|
439
|
+
const mappedStage = (isComplete ? 'sync' : specStatus === 'active' ? 'run' : 'plan') as Stage
|
|
440
|
+
|
|
441
|
+
// Count tags for progress
|
|
442
|
+
const tagMatches = specContent.match(/\bTAG-[A-Z]+-\d+\b/g) || []
|
|
443
|
+
const tagCount = tagMatches.length
|
|
444
|
+
const completedTagMatches = specContent.match(/\[x\]\s*\*\*TAG-[A-Z]+-\d+\*\*/gi) || []
|
|
445
|
+
const completedTags = completedTagMatches.length
|
|
446
|
+
const progress = isComplete ? 100 : (tagCount > 0 ? Math.round((completedTags / tagCount) * 100) : 0)
|
|
447
|
+
|
|
448
|
+
// Update change with current status from spec.md
|
|
449
|
+
change = {
|
|
450
|
+
...change,
|
|
451
|
+
status: mappedStatus,
|
|
452
|
+
current_stage: mappedStage,
|
|
453
|
+
progress,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
// Failed to read spec.md, continue with DB values
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} else {
|
|
463
|
+
// DB record doesn't exist - try to find MoAI SPEC in filesystem
|
|
464
|
+
// First try with projectId parameter, then fall back to active project
|
|
465
|
+
project = projectId
|
|
466
|
+
? config.projects.find((p) => p.id === projectId)
|
|
467
|
+
: config.projects.find((p) => p.is_active)
|
|
468
|
+
|
|
469
|
+
if (!project) {
|
|
470
|
+
return null
|
|
471
|
+
}
|
|
472
|
+
projectPath = project.path
|
|
473
|
+
|
|
474
|
+
// For remote projects, check via SSH
|
|
475
|
+
if (project.remote) {
|
|
476
|
+
const plugin = await getRemotePlugin()
|
|
477
|
+
if (!plugin) {
|
|
478
|
+
return null
|
|
479
|
+
}
|
|
480
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
481
|
+
if (!server) {
|
|
482
|
+
return null
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Try to read spec.md to verify SPEC exists on remote
|
|
486
|
+
const remoteSpecPath = `${projectPath}/.moai/specs/${specId}/spec.md`
|
|
487
|
+
try {
|
|
488
|
+
const specContent = await plugin.readRemoteFile(server, remoteSpecPath)
|
|
489
|
+
// Extract title from frontmatter
|
|
490
|
+
let title = specId
|
|
491
|
+
const titleMatch = specContent.match(/^title:\s+(.+)$/m)
|
|
492
|
+
if (titleMatch) {
|
|
493
|
+
title = titleMatch[1].trim().replace(/^["']|["']$/g, '')
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Extract status from frontmatter
|
|
497
|
+
let specStatus = 'draft'
|
|
498
|
+
const statusMatch = specContent.match(/^status:\s+(.+)$/m)
|
|
499
|
+
if (statusMatch) {
|
|
500
|
+
specStatus = statusMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase()
|
|
501
|
+
}
|
|
502
|
+
// Count tags from content (TAG-XXX-NNN pattern)
|
|
503
|
+
const tagMatches = specContent.match(/\bTAG-[A-Z]+-\d+\b/g) || []
|
|
504
|
+
const tagCount = tagMatches.length
|
|
505
|
+
// Count completed tags (marked with [x])
|
|
506
|
+
const completedTagMatches = specContent.match(/\[x\]\s*\*\*TAG-[A-Z]+-\d+\*\*/gi) || []
|
|
507
|
+
const completedTags = completedTagMatches.length
|
|
508
|
+
|
|
509
|
+
// Map status to ChangeStatus (same logic as local SPEC)
|
|
510
|
+
// 'implemented', 'complete', and 'completed' are all considered done
|
|
511
|
+
const isComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
|
|
512
|
+
const mappedStatus = (isComplete ? 'done' : specStatus === 'active' ? 'in_progress' : 'pending') as ChangeStatus
|
|
513
|
+
const mappedStage = (isComplete ? 'sync' : specStatus === 'active' ? 'run' : 'plan') as Stage
|
|
514
|
+
// If marked complete but no TAGs found in spec.md, use 100% progress
|
|
515
|
+
const progress = isComplete ? 100 : (tagCount > 0 ? Math.round((completedTags / tagCount) * 100) : 0)
|
|
516
|
+
|
|
517
|
+
// Create a virtual change record for remote SPEC
|
|
518
|
+
change = {
|
|
519
|
+
id: specId,
|
|
520
|
+
project_id: project.id,
|
|
521
|
+
title,
|
|
522
|
+
spec_path: remoteSpecPath,
|
|
523
|
+
status: mappedStatus,
|
|
524
|
+
current_stage: mappedStage,
|
|
525
|
+
progress,
|
|
526
|
+
created_at: Date.now(),
|
|
527
|
+
updated_at: Date.now(),
|
|
528
|
+
}
|
|
529
|
+
} catch {
|
|
530
|
+
// SPEC doesn't exist on remote
|
|
531
|
+
return null
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
// Check if MoAI SPEC exists in local filesystem
|
|
535
|
+
const moaiSpec = await getMoaiSpec(projectPath, specId)
|
|
536
|
+
if (!moaiSpec) {
|
|
537
|
+
return null
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Create a virtual change record from filesystem data
|
|
541
|
+
change = {
|
|
542
|
+
id: specId,
|
|
543
|
+
project_id: project.id,
|
|
544
|
+
title: moaiSpec.title,
|
|
545
|
+
spec_path: moaiSpec.path,
|
|
546
|
+
status: (moaiSpec.status === 'complete' ? 'done' : moaiSpec.status === 'active' ? 'in_progress' : 'pending') as ChangeStatus,
|
|
547
|
+
current_stage: (moaiSpec.status === 'complete' ? 'sync' : moaiSpec.status === 'active' ? 'run' : 'plan') as Stage,
|
|
548
|
+
progress: moaiSpec.tagCount > 0 ? Math.round((moaiSpec.completedTags / moaiSpec.tagCount) * 100) : 0,
|
|
549
|
+
created_at: Date.now(),
|
|
550
|
+
updated_at: Date.now(),
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const specsDir = join(projectPath, '.moai', 'specs', specId)
|
|
556
|
+
|
|
557
|
+
// Check if project is remote
|
|
558
|
+
const isRemote = !!project.remote
|
|
559
|
+
let plugin: RemotePlugin | null = null
|
|
560
|
+
let server: unknown = null
|
|
561
|
+
|
|
562
|
+
if (isRemote) {
|
|
563
|
+
plugin = await getRemotePlugin()
|
|
564
|
+
if (plugin) {
|
|
565
|
+
server = await plugin.getRemoteServerById(project.remote!.serverId)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Read spec.md
|
|
570
|
+
let specContent: string | null = null
|
|
571
|
+
let specTitle = change.title
|
|
572
|
+
const specPath = isRemote ? `${specsDir}/spec.md` : join(specsDir, 'spec.md')
|
|
573
|
+
|
|
574
|
+
if (isRemote && plugin && server) {
|
|
575
|
+
try {
|
|
576
|
+
specContent = await plugin.readRemoteFile(server, specPath)
|
|
577
|
+
// Try to extract title from spec.md frontmatter
|
|
578
|
+
const titleMatch = specContent.match(/^title:\s+(.+)$/m)
|
|
579
|
+
if (titleMatch) {
|
|
580
|
+
specTitle = titleMatch[1].trim().replace(/^["']|["']$/g, '')
|
|
581
|
+
}
|
|
582
|
+
} catch {
|
|
583
|
+
// spec.md not readable on remote
|
|
584
|
+
}
|
|
585
|
+
} else if (!isRemote && existsSync(specPath)) {
|
|
586
|
+
try {
|
|
587
|
+
specContent = await readFile(specPath, 'utf-8')
|
|
588
|
+
// Try to extract title from spec.md frontmatter
|
|
589
|
+
const titleMatch = specContent.match(/^title:\s+(.+)$/m)
|
|
590
|
+
if (titleMatch) {
|
|
591
|
+
specTitle = titleMatch[1].trim().replace(/^["']|["']$/g, '')
|
|
592
|
+
}
|
|
593
|
+
} catch {
|
|
594
|
+
// spec.md not readable
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Read plan.md and parse TAGs
|
|
599
|
+
let planContent: string | null = null
|
|
600
|
+
let tags = null
|
|
601
|
+
let tagProgress = null
|
|
602
|
+
const planPath = isRemote ? `${specsDir}/plan.md` : join(specsDir, 'plan.md')
|
|
603
|
+
|
|
604
|
+
if (isRemote && plugin && server) {
|
|
605
|
+
try {
|
|
606
|
+
planContent = await plugin.readRemoteFile(server, planPath)
|
|
607
|
+
const parsed = parsePlanFile(planContent)
|
|
608
|
+
tags = parsed.tags
|
|
609
|
+
// Calculate tag progress from parsed content
|
|
610
|
+
if (parsed.tags.length > 0) {
|
|
611
|
+
const completed = parsed.tags.filter((t) => t.completed).length
|
|
612
|
+
const total = parsed.tags.length
|
|
613
|
+
tagProgress = { completed, total, percentage: Math.round((completed / total) * 100) }
|
|
614
|
+
}
|
|
615
|
+
// Note: If no TAGs, tagProgress will be calculated from acceptance.md below
|
|
616
|
+
} catch {
|
|
617
|
+
// plan.md not readable on remote
|
|
618
|
+
}
|
|
619
|
+
} else if (!isRemote && existsSync(planPath)) {
|
|
620
|
+
try {
|
|
621
|
+
planContent = await readFile(planPath, 'utf-8')
|
|
622
|
+
const parsed = parsePlanFile(planContent)
|
|
623
|
+
tags = parsed.tags
|
|
624
|
+
// Calculate tag progress from parsed content (same as remote path)
|
|
625
|
+
if (parsed.tags.length > 0) {
|
|
626
|
+
const completed = parsed.tags.filter((t) => t.completed).length
|
|
627
|
+
const total = parsed.tags.length
|
|
628
|
+
tagProgress = { completed, total, percentage: Math.round((completed / total) * 100) }
|
|
629
|
+
}
|
|
630
|
+
// Note: If no TAGs, tagProgress will be calculated from acceptance.md below
|
|
631
|
+
} catch {
|
|
632
|
+
// plan.md not readable or parse error
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Read acceptance.md and parse criteria
|
|
637
|
+
let acceptanceContent: string | null = null
|
|
638
|
+
let criteria = null
|
|
639
|
+
const acceptancePath = isRemote ? `${specsDir}/acceptance.md` : join(specsDir, 'acceptance.md')
|
|
640
|
+
|
|
641
|
+
if (isRemote && plugin && server) {
|
|
642
|
+
try {
|
|
643
|
+
acceptanceContent = await plugin.readRemoteFile(server, acceptancePath)
|
|
644
|
+
const parsed = parseAcceptanceFile(acceptanceContent)
|
|
645
|
+
criteria = parsed.criteria
|
|
646
|
+
// Fallback: use acceptance.md checklists for progress if no TAGs found
|
|
647
|
+
if (!tagProgress && acceptanceContent) {
|
|
648
|
+
tagProgress = countMarkdownChecklists(acceptanceContent)
|
|
649
|
+
}
|
|
650
|
+
} catch {
|
|
651
|
+
// acceptance.md not readable on remote
|
|
652
|
+
}
|
|
653
|
+
} else if (!isRemote && existsSync(acceptancePath)) {
|
|
654
|
+
try {
|
|
655
|
+
acceptanceContent = await readFile(acceptancePath, 'utf-8')
|
|
656
|
+
const parsed = parseAcceptanceFile(acceptanceContent)
|
|
657
|
+
criteria = parsed.criteria
|
|
658
|
+
// Fallback: use acceptance.md checklists for progress if no TAGs found
|
|
659
|
+
if (!tagProgress && acceptanceContent) {
|
|
660
|
+
tagProgress = countMarkdownChecklists(acceptanceContent)
|
|
661
|
+
}
|
|
662
|
+
} catch {
|
|
663
|
+
// acceptance.md not readable or parse error
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Check if SPEC is marked as complete/implemented in frontmatter
|
|
668
|
+
let isStatusComplete = false
|
|
669
|
+
if (specContent) {
|
|
670
|
+
const statusMatch = specContent.match(/^status:\s*(\w+)/mi)
|
|
671
|
+
if (statusMatch) {
|
|
672
|
+
const specStatus = statusMatch[1].toLowerCase()
|
|
673
|
+
isStatusComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// If status is complete but no tagProgress found, show 100%
|
|
678
|
+
if (isStatusComplete && (!tagProgress || tagProgress.percentage === 0)) {
|
|
679
|
+
tagProgress = { completed: 1, total: 1, percentage: 100 }
|
|
680
|
+
// Also update change.progress for main progress display
|
|
681
|
+
change.progress = 100
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Get stages for compatibility with OpenSpec format
|
|
685
|
+
let stages = getChangeStages(specId, change.project_id)
|
|
686
|
+
|
|
687
|
+
// For MoAI SPECs: Map TAGs to 'plan' stage if no tasks in DB
|
|
688
|
+
if (tags && tags.length > 0) {
|
|
689
|
+
const totalStagesTasks = Object.values(stages).reduce((sum, s) => sum + s.total, 0)
|
|
690
|
+
|
|
691
|
+
// If no tasks in DB, populate stages from TAGs
|
|
692
|
+
if (totalStagesTasks === 0) {
|
|
693
|
+
stages = {
|
|
694
|
+
spec: { total: 0, completed: 0, tasks: [] },
|
|
695
|
+
changes: { total: 0, completed: 0, tasks: [] },
|
|
696
|
+
task: { total: 0, completed: 0, tasks: [] },
|
|
697
|
+
code: { total: 0, completed: 0, tasks: [] },
|
|
698
|
+
test: { total: 0, completed: 0, tasks: [] },
|
|
699
|
+
commit: { total: 0, completed: 0, tasks: [] },
|
|
700
|
+
docs: { total: 0, completed: 0, tasks: [] },
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Map TAGs to 'plan' stage (using 'task' for UI compatibility)
|
|
704
|
+
for (const tag of tags) {
|
|
705
|
+
stages.task.total++
|
|
706
|
+
if (tag.completed) {
|
|
707
|
+
stages.task.completed++
|
|
708
|
+
}
|
|
709
|
+
stages.task.tasks.push({
|
|
710
|
+
id: tag.id,
|
|
711
|
+
changeId: specId,
|
|
712
|
+
stage: 'task',
|
|
713
|
+
title: tag.title,
|
|
714
|
+
description: tag.description || null,
|
|
715
|
+
status: tag.completed ? 'done' : 'todo',
|
|
716
|
+
priority: 'medium',
|
|
717
|
+
tags: [],
|
|
718
|
+
assignee: null,
|
|
719
|
+
order: parseInt(tag.id.replace('TAG-', ''), 10) || 0,
|
|
720
|
+
groupTitle: null,
|
|
721
|
+
groupOrder: 0,
|
|
722
|
+
taskOrder: parseInt(tag.id.replace('TAG-', ''), 10) || 0,
|
|
723
|
+
majorTitle: null,
|
|
724
|
+
subOrder: 0,
|
|
725
|
+
displayId: tag.id,
|
|
726
|
+
createdAt: new Date().toISOString(),
|
|
727
|
+
updatedAt: new Date().toISOString(),
|
|
728
|
+
archivedAt: null,
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Map DB status to frontend format
|
|
735
|
+
const frontendStatusMap: Record<string, string> = {
|
|
736
|
+
'done': 'completed',
|
|
737
|
+
'in_progress': 'active',
|
|
738
|
+
'pending': 'draft',
|
|
739
|
+
'active': 'active',
|
|
740
|
+
'completed': 'completed',
|
|
741
|
+
'archived': 'archived',
|
|
742
|
+
}
|
|
743
|
+
const frontendStatus = frontendStatusMap[change.status] || change.status
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
id: change.id,
|
|
747
|
+
title: specTitle,
|
|
748
|
+
type: 'spec',
|
|
749
|
+
status: frontendStatus,
|
|
750
|
+
currentStage: change.current_stage,
|
|
751
|
+
progress: change.progress,
|
|
752
|
+
createdAt: new Date(change.created_at).toISOString(),
|
|
753
|
+
updatedAt: new Date(change.updated_at).toISOString(),
|
|
754
|
+
spec: specContent ? { content: specContent, title: specTitle } : null,
|
|
755
|
+
plan: planContent ? { content: planContent, tags, progress: tagProgress } : null,
|
|
756
|
+
acceptance: acceptanceContent ? { content: acceptanceContent, criteria } : null,
|
|
757
|
+
stages,
|
|
758
|
+
}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
console.error('Error getting MoAI SPEC detail:', error)
|
|
761
|
+
return null
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// GET /changes/counts - 프로젝트별 Change 수 (상태별 집계)
|
|
766
|
+
flowRouter.get('/changes/counts', async (req, res) => {
|
|
767
|
+
try {
|
|
768
|
+
await initTaskDb()
|
|
769
|
+
const config = await loadConfig()
|
|
770
|
+
const sqlite = getSqlite()
|
|
771
|
+
|
|
772
|
+
const { status } = req.query
|
|
773
|
+
|
|
774
|
+
const counts: Record<string, number> = {}
|
|
775
|
+
const detailedCounts: Record<string, { active: number; completed: number; total: number }> = {}
|
|
776
|
+
|
|
777
|
+
const projectIds = config.projects.map((p) => p.id)
|
|
778
|
+
const placeholders = projectIds.map(() => '?').join(',')
|
|
779
|
+
|
|
780
|
+
const detailedResults = sqlite
|
|
781
|
+
.prepare(
|
|
782
|
+
`
|
|
783
|
+
SELECT
|
|
784
|
+
project_id,
|
|
785
|
+
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
|
|
786
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
787
|
+
COUNT(*) as total
|
|
788
|
+
FROM changes
|
|
789
|
+
WHERE project_id IN (${placeholders})
|
|
790
|
+
GROUP BY project_id
|
|
791
|
+
`
|
|
792
|
+
)
|
|
793
|
+
.all(...projectIds) as Array<{
|
|
794
|
+
project_id: string
|
|
795
|
+
active: number
|
|
796
|
+
completed: number
|
|
797
|
+
total: number
|
|
798
|
+
}>
|
|
799
|
+
|
|
800
|
+
for (const project of config.projects) {
|
|
801
|
+
const projectResult = detailedResults.find((r) => r.project_id === project.id)
|
|
802
|
+
|
|
803
|
+
let count = 0
|
|
804
|
+
if (status === 'active') {
|
|
805
|
+
count = projectResult?.active ?? 0
|
|
806
|
+
} else if (status === 'completed') {
|
|
807
|
+
count = projectResult?.completed ?? 0
|
|
808
|
+
} else {
|
|
809
|
+
count = projectResult?.total ?? 0
|
|
810
|
+
}
|
|
811
|
+
counts[project.id] = count
|
|
812
|
+
|
|
813
|
+
detailedCounts[project.id] = {
|
|
814
|
+
active: projectResult?.active ?? 0,
|
|
815
|
+
completed: projectResult?.completed ?? 0,
|
|
816
|
+
total: projectResult?.total ?? 0,
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const responseData = { counts, detailed: detailedCounts }
|
|
821
|
+
|
|
822
|
+
res.json({ success: true, data: responseData })
|
|
823
|
+
} catch (error) {
|
|
824
|
+
console.error('Error getting change counts:', error)
|
|
825
|
+
res.status(500).json({
|
|
826
|
+
success: false,
|
|
827
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
// GET /changes - Flow Changes 목록 (모든 프로젝트)
|
|
833
|
+
flowRouter.get('/changes', async (_req, res) => {
|
|
834
|
+
try {
|
|
835
|
+
await initTaskDb()
|
|
836
|
+
const config = await loadConfig()
|
|
837
|
+
|
|
838
|
+
if (config.projects.length === 0) {
|
|
839
|
+
return res.json({ success: true, data: { changes: [] } })
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const sqlite = getSqlite()
|
|
843
|
+
const allChanges: Array<{
|
|
844
|
+
id: string
|
|
845
|
+
type: string
|
|
846
|
+
projectId: string
|
|
847
|
+
projectName: string
|
|
848
|
+
title: string
|
|
849
|
+
specPath: string | null
|
|
850
|
+
status: string
|
|
851
|
+
currentStage: string
|
|
852
|
+
progress: number
|
|
853
|
+
createdAt: string
|
|
854
|
+
updatedAt: string
|
|
855
|
+
stages: ReturnType<typeof getChangeStages>
|
|
856
|
+
}> = []
|
|
857
|
+
|
|
858
|
+
// Scan all projects
|
|
859
|
+
for (const project of config.projects) {
|
|
860
|
+
// Get OpenSpec changes from DB
|
|
861
|
+
const dbChanges = sqlite
|
|
862
|
+
.prepare(
|
|
863
|
+
`
|
|
864
|
+
SELECT * FROM changes
|
|
865
|
+
WHERE project_id = ? AND status != 'archived'
|
|
866
|
+
ORDER BY updated_at DESC
|
|
867
|
+
`
|
|
868
|
+
)
|
|
869
|
+
.all(project.id) as Array<{
|
|
870
|
+
id: string
|
|
871
|
+
project_id: string
|
|
872
|
+
title: string
|
|
873
|
+
spec_path: string | null
|
|
874
|
+
status: ChangeStatus
|
|
875
|
+
current_stage: Stage
|
|
876
|
+
progress: number
|
|
877
|
+
created_at: number
|
|
878
|
+
updated_at: number
|
|
879
|
+
}>
|
|
880
|
+
|
|
881
|
+
for (const c of dbChanges) {
|
|
882
|
+
const stages = getChangeStages(c.id, project.id)
|
|
883
|
+
const progress = calculateProgress(stages)
|
|
884
|
+
const currentStage = determineCurrentStage(stages)
|
|
885
|
+
const type = c.spec_path?.startsWith('.moai/specs/') ? 'moai-spec' : 'openspec'
|
|
886
|
+
|
|
887
|
+
allChanges.push({
|
|
888
|
+
id: c.id,
|
|
889
|
+
type,
|
|
890
|
+
projectId: c.project_id,
|
|
891
|
+
projectName: project.name,
|
|
892
|
+
title: c.title,
|
|
893
|
+
specPath: c.spec_path,
|
|
894
|
+
status: c.status,
|
|
895
|
+
currentStage,
|
|
896
|
+
progress,
|
|
897
|
+
createdAt: new Date(c.created_at).toISOString(),
|
|
898
|
+
updatedAt: new Date(c.updated_at).toISOString(),
|
|
899
|
+
stages,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Scan MoAI SPECs from .moai/specs/ directory (local or remote)
|
|
904
|
+
try {
|
|
905
|
+
let moaiSpecs: Awaited<ReturnType<typeof scanMoaiSpecs>> = []
|
|
906
|
+
|
|
907
|
+
if (project.remote) {
|
|
908
|
+
// Remote SSH project
|
|
909
|
+
const plugin = await getRemotePlugin()
|
|
910
|
+
if (plugin) {
|
|
911
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
912
|
+
if (server) {
|
|
913
|
+
moaiSpecs = await scanRemoteMoaiSpecs(project.path, server, plugin)
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
} else {
|
|
917
|
+
// Local project
|
|
918
|
+
moaiSpecs = await scanMoaiSpecs(project.path)
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
for (const spec of moaiSpecs) {
|
|
922
|
+
if (spec.status === 'archived') continue
|
|
923
|
+
|
|
924
|
+
// Calculate fresh progress from scanned data
|
|
925
|
+
const freshProgress = spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0
|
|
926
|
+
|
|
927
|
+
// Check if already in DB - if so, UPDATE with fresh progress
|
|
928
|
+
const existingIdx = allChanges.findIndex((c) => c.id === spec.id && c.projectId === project.id)
|
|
929
|
+
if (existingIdx !== -1) {
|
|
930
|
+
// Update existing entry with fresh scanned progress
|
|
931
|
+
allChanges[existingIdx].progress = freshProgress
|
|
932
|
+
allChanges[existingIdx].totalTasks = spec.tagCount
|
|
933
|
+
allChanges[existingIdx].completedTasks = spec.completedTags
|
|
934
|
+
continue
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Map MoAI SPEC status to changes table status for consistency
|
|
938
|
+
// MoAI: draft, active, complete, archived
|
|
939
|
+
// Changes table: active, completed, archived
|
|
940
|
+
const mapSpecStatusToDbStatus = (specStatus: string): ChangeStatus => {
|
|
941
|
+
switch (specStatus) {
|
|
942
|
+
case 'complete':
|
|
943
|
+
case 'completed':
|
|
944
|
+
return 'completed'
|
|
945
|
+
case 'archived':
|
|
946
|
+
return 'archived'
|
|
947
|
+
default:
|
|
948
|
+
return 'active' // draft, active -> active
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
allChanges.push({
|
|
953
|
+
id: spec.id,
|
|
954
|
+
type: 'moai-spec',
|
|
955
|
+
projectId: project.id,
|
|
956
|
+
projectName: project.name,
|
|
957
|
+
title: spec.title,
|
|
958
|
+
specPath: `.moai/specs/${spec.id}`,
|
|
959
|
+
status: mapSpecStatusToDbStatus(spec.status),
|
|
960
|
+
currentStage: spec.status === 'complete' ? 'docs' : spec.status === 'active' ? 'code' : 'spec',
|
|
961
|
+
progress: spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0,
|
|
962
|
+
createdAt: spec.createdAt || new Date().toISOString(),
|
|
963
|
+
updatedAt: spec.updatedAt || new Date().toISOString(),
|
|
964
|
+
stages: getChangeStages(spec.id, project.id),
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
} catch (err) {
|
|
968
|
+
console.warn(`Failed to scan MoAI SPECs for ${project.name}:`, err)
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Sort by updatedAt (most recent first)
|
|
973
|
+
const changes = allChanges.sort((a, b) => {
|
|
974
|
+
const aTime = new Date(a.updatedAt).getTime()
|
|
975
|
+
const bTime = new Date(b.updatedAt).getTime()
|
|
976
|
+
return bTime - aTime
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
res.json({ success: true, data: { changes } })
|
|
980
|
+
} catch (error) {
|
|
981
|
+
console.error('Error listing flow changes:', error)
|
|
982
|
+
res.status(500).json({ success: false, error: 'Failed to list flow changes' })
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
|
|
986
|
+
// GET /changes/:id - Flow Change 상세 (stages 포함, MoAI SPEC 지원)
|
|
987
|
+
flowRouter.get('/changes/:id', async (req, res) => {
|
|
988
|
+
try {
|
|
989
|
+
await initTaskDb()
|
|
990
|
+
const config = await loadConfig()
|
|
991
|
+
// URL 쿼리에서 projectId 받기, 없으면 activeProjectId 사용
|
|
992
|
+
const queryProjectId = req.query.projectId as string | undefined
|
|
993
|
+
const targetProjectId = queryProjectId || config.activeProjectId
|
|
994
|
+
|
|
995
|
+
// Check if this is a MoAI SPEC ID (SPEC-XXX format)
|
|
996
|
+
if (isMoaiSpecId(req.params.id)) {
|
|
997
|
+
const specDetail = await getMoaiSpecDetail(req.params.id, targetProjectId)
|
|
998
|
+
if (specDetail) {
|
|
999
|
+
return res.json({
|
|
1000
|
+
success: true,
|
|
1001
|
+
data: {
|
|
1002
|
+
change: specDetail,
|
|
1003
|
+
},
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const sqlite = getSqlite()
|
|
1009
|
+
|
|
1010
|
+
// 지정된 프로젝트에서 우선 조회 (같은 changeId가 여러 프로젝트에 있을 수 있음)
|
|
1011
|
+
let change = targetProjectId
|
|
1012
|
+
? (sqlite
|
|
1013
|
+
.prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
|
|
1014
|
+
.get(req.params.id, targetProjectId) as {
|
|
1015
|
+
id: string
|
|
1016
|
+
project_id: string
|
|
1017
|
+
title: string
|
|
1018
|
+
spec_path: string | null
|
|
1019
|
+
status: ChangeStatus
|
|
1020
|
+
current_stage: Stage
|
|
1021
|
+
progress: number
|
|
1022
|
+
created_at: number
|
|
1023
|
+
updated_at: number
|
|
1024
|
+
} | undefined)
|
|
1025
|
+
: undefined
|
|
1026
|
+
|
|
1027
|
+
// 활성 프로젝트에 없으면 전체에서 조회 (fallback)
|
|
1028
|
+
if (!change) {
|
|
1029
|
+
change = sqlite
|
|
1030
|
+
.prepare(`SELECT * FROM changes WHERE id = ?`)
|
|
1031
|
+
.get(req.params.id) as typeof change
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!change) {
|
|
1035
|
+
return res.status(404).json({ success: false, error: 'Change not found' })
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const project = config.projects.find((p) => p.id === change.project_id)
|
|
1039
|
+
if (!project) {
|
|
1040
|
+
return res.status(404).json({ success: false, error: 'Project not found for this change' })
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// 원격 프로젝트인 경우 SSH로 tasks.md 읽어서 stages 계산
|
|
1044
|
+
// 로컬 프로젝트인 경우 DB에서 조회
|
|
1045
|
+
const stages = project.remote
|
|
1046
|
+
? await getRemoteChangeStages(change.id, project as { path: string; remote: { serverId: string } })
|
|
1047
|
+
: getChangeStages(change.id, project.id)
|
|
1048
|
+
const progress = calculateProgress(stages)
|
|
1049
|
+
const currentStage = determineCurrentStage(stages)
|
|
1050
|
+
|
|
1051
|
+
let gitCreatedAt: string | null = null
|
|
1052
|
+
let gitUpdatedAt: string | null = null
|
|
1053
|
+
// 원격 프로젝트가 아닌 경우에만 git 명령 실행
|
|
1054
|
+
if (!project.remote) {
|
|
1055
|
+
try {
|
|
1056
|
+
const relativeChangeDir = `openspec/changes/${change.id}`
|
|
1057
|
+
const { stdout: updatedStdout } = await execAsync(
|
|
1058
|
+
`git log -1 --format="%aI" -- "${relativeChangeDir}"`,
|
|
1059
|
+
{ cwd: project.path }
|
|
1060
|
+
)
|
|
1061
|
+
if (updatedStdout.trim()) {
|
|
1062
|
+
gitUpdatedAt = updatedStdout.trim()
|
|
1063
|
+
}
|
|
1064
|
+
const { stdout: createdStdout } = await execAsync(
|
|
1065
|
+
`git log --diff-filter=A --format="%aI" -- "${relativeChangeDir}" | tail -1`,
|
|
1066
|
+
{ cwd: project.path }
|
|
1067
|
+
)
|
|
1068
|
+
if (createdStdout.trim()) {
|
|
1069
|
+
gitCreatedAt = createdStdout.trim()
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
// Git 명령 실패 시 DB 값 사용
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
res.json({
|
|
1077
|
+
success: true,
|
|
1078
|
+
data: {
|
|
1079
|
+
change: {
|
|
1080
|
+
id: change.id,
|
|
1081
|
+
projectId: change.project_id,
|
|
1082
|
+
title: change.title,
|
|
1083
|
+
specPath: change.spec_path,
|
|
1084
|
+
status: change.status,
|
|
1085
|
+
currentStage,
|
|
1086
|
+
progress,
|
|
1087
|
+
createdAt: gitCreatedAt || new Date(change.created_at).toISOString(),
|
|
1088
|
+
updatedAt: gitUpdatedAt || new Date(change.updated_at).toISOString(),
|
|
1089
|
+
},
|
|
1090
|
+
stages,
|
|
1091
|
+
},
|
|
1092
|
+
})
|
|
1093
|
+
} catch (error) {
|
|
1094
|
+
console.error('Error getting flow change:', error)
|
|
1095
|
+
res.status(500).json({ success: false, error: 'Failed to get flow change' })
|
|
1096
|
+
}
|
|
1097
|
+
})
|
|
1098
|
+
|
|
1099
|
+
// POST /sync - OpenSpec에서 Changes 동기화 (모든 프로젝트)
|
|
1100
|
+
flowRouter.post('/sync', async (_req, res) => {
|
|
1101
|
+
try {
|
|
1102
|
+
await initTaskDb()
|
|
1103
|
+
const config = await loadConfig()
|
|
1104
|
+
|
|
1105
|
+
if (!config.projects.length) {
|
|
1106
|
+
return res.json({ success: true, data: { synced: 0, created: 0, updated: 0, projects: 0 } })
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const sqlite = getSqlite()
|
|
1110
|
+
let totalCreated = 0
|
|
1111
|
+
let totalUpdated = 0
|
|
1112
|
+
let projectsSynced = 0
|
|
1113
|
+
|
|
1114
|
+
for (const project of config.projects) {
|
|
1115
|
+
const openspecDir = join(project.path, 'openspec', 'changes')
|
|
1116
|
+
let entries
|
|
1117
|
+
try {
|
|
1118
|
+
entries = await readdir(openspecDir, { withFileTypes: true })
|
|
1119
|
+
} catch {
|
|
1120
|
+
continue
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
projectsSynced++
|
|
1124
|
+
|
|
1125
|
+
for (const entry of entries) {
|
|
1126
|
+
if (!entry.isDirectory() || entry.name === 'archive') continue
|
|
1127
|
+
|
|
1128
|
+
const changeId = entry.name
|
|
1129
|
+
const changeDir = join(openspecDir, changeId)
|
|
1130
|
+
|
|
1131
|
+
let title = changeId
|
|
1132
|
+
const specPath = `openspec/changes/${changeId}/proposal.md`
|
|
1133
|
+
try {
|
|
1134
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
1135
|
+
const proposalContent = await readFile(proposalPath, 'utf-8')
|
|
1136
|
+
const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
|
|
1137
|
+
if (titleMatch) {
|
|
1138
|
+
title = titleMatch[1].trim()
|
|
1139
|
+
}
|
|
1140
|
+
} catch {
|
|
1141
|
+
// proposal.md not found
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const existing = sqlite
|
|
1145
|
+
.prepare('SELECT id FROM changes WHERE id = ? AND project_id = ?')
|
|
1146
|
+
.get(changeId, project.id)
|
|
1147
|
+
const now = Date.now()
|
|
1148
|
+
|
|
1149
|
+
if (existing) {
|
|
1150
|
+
sqlite
|
|
1151
|
+
.prepare(
|
|
1152
|
+
`
|
|
1153
|
+
UPDATE changes SET title = ?, spec_path = ?, updated_at = ? WHERE id = ? AND project_id = ?
|
|
1154
|
+
`
|
|
1155
|
+
)
|
|
1156
|
+
.run(title, specPath, now, changeId, project.id)
|
|
1157
|
+
totalUpdated++
|
|
1158
|
+
} else {
|
|
1159
|
+
sqlite
|
|
1160
|
+
.prepare(
|
|
1161
|
+
`
|
|
1162
|
+
INSERT INTO changes (id, project_id, title, spec_path, status, current_stage, progress, created_at, updated_at)
|
|
1163
|
+
VALUES (?, ?, ?, ?, 'active', 'spec', 0, ?, ?)
|
|
1164
|
+
`
|
|
1165
|
+
)
|
|
1166
|
+
.run(changeId, project.id, title, specPath, now, now)
|
|
1167
|
+
totalCreated++
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// Sync tasks from tasks.md
|
|
1171
|
+
try {
|
|
1172
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
1173
|
+
const tasksContent = await readFile(tasksPath, 'utf-8')
|
|
1174
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
1175
|
+
|
|
1176
|
+
interface ExtendedGroup {
|
|
1177
|
+
title: string
|
|
1178
|
+
tasks: Array<{
|
|
1179
|
+
title: string
|
|
1180
|
+
completed: boolean
|
|
1181
|
+
lineNumber: number
|
|
1182
|
+
displayId?: string
|
|
1183
|
+
}>
|
|
1184
|
+
majorOrder?: number
|
|
1185
|
+
majorTitle?: string
|
|
1186
|
+
subOrder?: number
|
|
1187
|
+
groupTitle?: string
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const parsedDisplayIds = new Set<string>()
|
|
1191
|
+
|
|
1192
|
+
for (const group of parsed.groups as ExtendedGroup[]) {
|
|
1193
|
+
const majorOrder = group.majorOrder ?? 1
|
|
1194
|
+
const majorTitle = group.majorTitle ?? group.title
|
|
1195
|
+
const subOrder = group.subOrder ?? 1
|
|
1196
|
+
const groupTitle = group.groupTitle ?? group.title
|
|
1197
|
+
|
|
1198
|
+
for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
|
|
1199
|
+
const task = group.tasks[taskIdx]
|
|
1200
|
+
const taskOrder = taskIdx + 1
|
|
1201
|
+
const displayId = task.displayId || null
|
|
1202
|
+
|
|
1203
|
+
if (displayId) {
|
|
1204
|
+
parsedDisplayIds.add(displayId)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
let existingTask: { id: number } | undefined
|
|
1208
|
+
|
|
1209
|
+
if (displayId) {
|
|
1210
|
+
existingTask = sqlite
|
|
1211
|
+
.prepare(
|
|
1212
|
+
`
|
|
1213
|
+
SELECT id FROM tasks WHERE change_id = ? AND display_id = ?
|
|
1214
|
+
`
|
|
1215
|
+
)
|
|
1216
|
+
.get(changeId, displayId) as { id: number } | undefined
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (!existingTask) {
|
|
1220
|
+
existingTask = sqlite
|
|
1221
|
+
.prepare(
|
|
1222
|
+
`
|
|
1223
|
+
SELECT id FROM tasks WHERE change_id = ? AND title = ?
|
|
1224
|
+
`
|
|
1225
|
+
)
|
|
1226
|
+
.get(changeId, task.title) as { id: number } | undefined
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (existingTask) {
|
|
1230
|
+
const newStatus = task.completed ? 'done' : 'todo'
|
|
1231
|
+
sqlite
|
|
1232
|
+
.prepare(
|
|
1233
|
+
`
|
|
1234
|
+
UPDATE tasks
|
|
1235
|
+
SET title = ?,
|
|
1236
|
+
status = ?,
|
|
1237
|
+
group_title = ?,
|
|
1238
|
+
group_order = ?,
|
|
1239
|
+
task_order = ?,
|
|
1240
|
+
major_title = ?,
|
|
1241
|
+
sub_order = ?,
|
|
1242
|
+
display_id = ?,
|
|
1243
|
+
project_id = ?,
|
|
1244
|
+
updated_at = ?
|
|
1245
|
+
WHERE id = ?
|
|
1246
|
+
`
|
|
1247
|
+
)
|
|
1248
|
+
.run(
|
|
1249
|
+
task.title,
|
|
1250
|
+
newStatus,
|
|
1251
|
+
groupTitle,
|
|
1252
|
+
majorOrder,
|
|
1253
|
+
taskOrder,
|
|
1254
|
+
majorTitle,
|
|
1255
|
+
subOrder,
|
|
1256
|
+
displayId,
|
|
1257
|
+
project.id,
|
|
1258
|
+
now,
|
|
1259
|
+
existingTask.id
|
|
1260
|
+
)
|
|
1261
|
+
} else {
|
|
1262
|
+
sqlite
|
|
1263
|
+
.prepare(`UPDATE sequences SET value = value + 1 WHERE name = 'task_openspec'`)
|
|
1264
|
+
.run()
|
|
1265
|
+
const seqResult = sqlite
|
|
1266
|
+
.prepare(`SELECT value FROM sequences WHERE name = 'task_openspec'`)
|
|
1267
|
+
.get() as { value: number }
|
|
1268
|
+
const newId = seqResult.value
|
|
1269
|
+
|
|
1270
|
+
sqlite
|
|
1271
|
+
.prepare(
|
|
1272
|
+
`
|
|
1273
|
+
INSERT INTO tasks (
|
|
1274
|
+
id, project_id, change_id, stage, title, status, priority, "order",
|
|
1275
|
+
group_title, group_order, task_order, major_title, sub_order,
|
|
1276
|
+
display_id, origin, created_at, updated_at
|
|
1277
|
+
)
|
|
1278
|
+
VALUES (?, ?, ?, 'task', ?, ?, 'medium', ?, ?, ?, ?, ?, ?, ?, 'openspec', ?, ?)
|
|
1279
|
+
`
|
|
1280
|
+
)
|
|
1281
|
+
.run(
|
|
1282
|
+
newId,
|
|
1283
|
+
project.id,
|
|
1284
|
+
changeId,
|
|
1285
|
+
task.title,
|
|
1286
|
+
task.completed ? 'done' : 'todo',
|
|
1287
|
+
task.lineNumber,
|
|
1288
|
+
groupTitle,
|
|
1289
|
+
majorOrder,
|
|
1290
|
+
taskOrder,
|
|
1291
|
+
majorTitle,
|
|
1292
|
+
subOrder,
|
|
1293
|
+
displayId,
|
|
1294
|
+
now,
|
|
1295
|
+
now
|
|
1296
|
+
)
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (parsedDisplayIds.size > 0) {
|
|
1302
|
+
const dbTasks = sqlite
|
|
1303
|
+
.prepare(
|
|
1304
|
+
`
|
|
1305
|
+
SELECT id, display_id FROM tasks
|
|
1306
|
+
WHERE change_id = ? AND display_id IS NOT NULL AND status != 'archived'
|
|
1307
|
+
`
|
|
1308
|
+
)
|
|
1309
|
+
.all(changeId) as Array<{ id: number; display_id: string }>
|
|
1310
|
+
|
|
1311
|
+
for (const dbTask of dbTasks) {
|
|
1312
|
+
if (!parsedDisplayIds.has(dbTask.display_id)) {
|
|
1313
|
+
sqlite
|
|
1314
|
+
.prepare(
|
|
1315
|
+
`
|
|
1316
|
+
UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?
|
|
1317
|
+
`
|
|
1318
|
+
)
|
|
1319
|
+
.run(now, now, dbTask.id)
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
} catch {
|
|
1324
|
+
// tasks.md not found or parse error
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Sync MoAI SPECs (.moai/specs/)
|
|
1330
|
+
for (const project of config.projects) {
|
|
1331
|
+
try {
|
|
1332
|
+
const moaiResult = await scanMoaiSpecs(project.path, project.id)
|
|
1333
|
+
totalCreated += moaiResult.totalCreated
|
|
1334
|
+
totalUpdated += moaiResult.totalUpdated
|
|
1335
|
+
if (moaiResult.specsProcessed > 0) {
|
|
1336
|
+
console.log(`[Sync] MoAI SPECs synced for ${project.id}: ${moaiResult.specsProcessed} specs processed`)
|
|
1337
|
+
}
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
console.error(`Error syncing MoAI SPECs for ${project.id}:`, error)
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
res.json({
|
|
1344
|
+
success: true,
|
|
1345
|
+
data: {
|
|
1346
|
+
synced: totalCreated + totalUpdated,
|
|
1347
|
+
created: totalCreated,
|
|
1348
|
+
updated: totalUpdated,
|
|
1349
|
+
projects: projectsSynced,
|
|
1350
|
+
},
|
|
1351
|
+
})
|
|
1352
|
+
} catch (error) {
|
|
1353
|
+
console.error('Error syncing flow changes:', error)
|
|
1354
|
+
res.status(500).json({ success: false, error: 'Failed to sync flow changes' })
|
|
1355
|
+
}
|
|
1356
|
+
})
|
|
1357
|
+
|
|
1358
|
+
// GET /tasks - Flow Tasks 목록 (필터링)
|
|
1359
|
+
flowRouter.get('/tasks', async (req, res) => {
|
|
1360
|
+
try {
|
|
1361
|
+
await initTaskDb()
|
|
1362
|
+
const { changeId, stage, status, standalone, includeArchived, projectId, origin } = req.query
|
|
1363
|
+
|
|
1364
|
+
// projectId 쿼리 파라미터가 있으면 해당 프로젝트 사용, 없으면 활성 프로젝트
|
|
1365
|
+
const project = projectId ? await getProjectById(projectId as string) : await getActiveProject()
|
|
1366
|
+
|
|
1367
|
+
if (!project) {
|
|
1368
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
const sqlite = getSqlite()
|
|
1372
|
+
let sql = 'SELECT * FROM tasks WHERE project_id = ?'
|
|
1373
|
+
const params: unknown[] = [project.id]
|
|
1374
|
+
|
|
1375
|
+
if (standalone === 'true') {
|
|
1376
|
+
sql += ' AND change_id IS NULL'
|
|
1377
|
+
} else if (changeId) {
|
|
1378
|
+
sql += ' AND change_id = ?'
|
|
1379
|
+
params.push(changeId)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// origin 필터 지원 (backlog, inbox, openspec, imported)
|
|
1383
|
+
if (origin) {
|
|
1384
|
+
sql += ' AND origin = ?'
|
|
1385
|
+
params.push(origin)
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (stage) {
|
|
1389
|
+
sql += ' AND stage = ?'
|
|
1390
|
+
params.push(stage)
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (status) {
|
|
1394
|
+
sql += ' AND status = ?'
|
|
1395
|
+
params.push(status)
|
|
1396
|
+
} else if (includeArchived !== 'true') {
|
|
1397
|
+
sql += " AND status != 'archived'"
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
sql += ' ORDER BY "order", created_at'
|
|
1401
|
+
|
|
1402
|
+
const tasks = sqlite.prepare(sql).all(...params) as Array<{
|
|
1403
|
+
id: number
|
|
1404
|
+
change_id: string | null
|
|
1405
|
+
stage: Stage
|
|
1406
|
+
origin: TaskOrigin | null
|
|
1407
|
+
title: string
|
|
1408
|
+
description: string | null
|
|
1409
|
+
status: string
|
|
1410
|
+
priority: string
|
|
1411
|
+
tags: string | null
|
|
1412
|
+
assignee: string | null
|
|
1413
|
+
order: number
|
|
1414
|
+
display_id: string | null
|
|
1415
|
+
// Backlog 확장 필드
|
|
1416
|
+
parent_task_id: number | null
|
|
1417
|
+
blocked_by: string | null
|
|
1418
|
+
plan: string | null
|
|
1419
|
+
acceptance_criteria: string | null
|
|
1420
|
+
notes: string | null
|
|
1421
|
+
due_date: number | null
|
|
1422
|
+
milestone: string | null
|
|
1423
|
+
backlog_file_id: string | null
|
|
1424
|
+
created_at: number
|
|
1425
|
+
updated_at: number
|
|
1426
|
+
archived_at: number | null
|
|
1427
|
+
}>
|
|
1428
|
+
|
|
1429
|
+
const formatted = tasks.map((t) => ({
|
|
1430
|
+
id: t.id,
|
|
1431
|
+
changeId: t.change_id,
|
|
1432
|
+
stage: t.stage,
|
|
1433
|
+
origin: t.origin,
|
|
1434
|
+
title: t.title,
|
|
1435
|
+
description: t.description,
|
|
1436
|
+
status: t.status,
|
|
1437
|
+
priority: t.priority,
|
|
1438
|
+
tags: t.tags ? JSON.parse(t.tags) : [],
|
|
1439
|
+
assignee: t.assignee,
|
|
1440
|
+
order: t.order,
|
|
1441
|
+
displayId: t.display_id,
|
|
1442
|
+
// Backlog 확장 필드
|
|
1443
|
+
parentTaskId: t.parent_task_id,
|
|
1444
|
+
blockedBy: t.blocked_by ? JSON.parse(t.blocked_by) : null,
|
|
1445
|
+
plan: t.plan,
|
|
1446
|
+
acceptanceCriteria: t.acceptance_criteria,
|
|
1447
|
+
notes: t.notes,
|
|
1448
|
+
dueDate: t.due_date ? new Date(t.due_date).toISOString() : null,
|
|
1449
|
+
milestone: t.milestone,
|
|
1450
|
+
backlogFileId: t.backlog_file_id,
|
|
1451
|
+
createdAt: new Date(t.created_at).toISOString(),
|
|
1452
|
+
updatedAt: new Date(t.updated_at).toISOString(),
|
|
1453
|
+
archivedAt: t.archived_at ? new Date(t.archived_at).toISOString() : null,
|
|
1454
|
+
}))
|
|
1455
|
+
|
|
1456
|
+
res.json({ success: true, data: { tasks: formatted } })
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
console.error('Error listing flow tasks:', error)
|
|
1459
|
+
res.status(500).json({ success: false, error: 'Failed to list flow tasks' })
|
|
1460
|
+
}
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
// POST /tasks - Flow Task 생성
|
|
1464
|
+
flowRouter.post('/tasks', async (req, res) => {
|
|
1465
|
+
try {
|
|
1466
|
+
await initTaskDb()
|
|
1467
|
+
const project = await getActiveProject()
|
|
1468
|
+
if (!project) {
|
|
1469
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1470
|
+
}
|
|
1471
|
+
const { changeId, stage, title, description, priority } = req.body
|
|
1472
|
+
|
|
1473
|
+
if (!title) {
|
|
1474
|
+
return res.status(400).json({ success: false, error: 'Title is required' })
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const sqlite = getSqlite()
|
|
1478
|
+
const now = Date.now()
|
|
1479
|
+
|
|
1480
|
+
const result = sqlite
|
|
1481
|
+
.prepare(
|
|
1482
|
+
`
|
|
1483
|
+
INSERT INTO tasks (project_id, change_id, stage, title, description, status, priority, "order", created_at, updated_at)
|
|
1484
|
+
VALUES (?, ?, ?, ?, ?, 'todo', ?, 0, ?, ?)
|
|
1485
|
+
`
|
|
1486
|
+
)
|
|
1487
|
+
.run(
|
|
1488
|
+
project.id,
|
|
1489
|
+
changeId || null,
|
|
1490
|
+
stage || 'task',
|
|
1491
|
+
title,
|
|
1492
|
+
description || null,
|
|
1493
|
+
priority || 'medium',
|
|
1494
|
+
now,
|
|
1495
|
+
now
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid)
|
|
1499
|
+
|
|
1500
|
+
emit('task:created', { task })
|
|
1501
|
+
|
|
1502
|
+
res.json({ success: true, data: { task } })
|
|
1503
|
+
} catch (error) {
|
|
1504
|
+
console.error('Error creating flow task:', error)
|
|
1505
|
+
res.status(500).json({ success: false, error: 'Failed to create flow task' })
|
|
1506
|
+
}
|
|
1507
|
+
})
|
|
1508
|
+
|
|
1509
|
+
// GET /changes/:id/proposal - Change의 proposal.md 내용
|
|
1510
|
+
flowRouter.get('/changes/:id/proposal', async (req, res) => {
|
|
1511
|
+
try {
|
|
1512
|
+
const changeId = req.params.id
|
|
1513
|
+
const project = await getProjectForChange(changeId)
|
|
1514
|
+
|
|
1515
|
+
if (!project) {
|
|
1516
|
+
return res.status(400).json({ success: false, error: 'Project not found for change' })
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const proposalPath = `${project.path}/openspec/changes/${changeId}/proposal.md`
|
|
1520
|
+
const archiveDir = `${project.path}/openspec/changes/archive`
|
|
1521
|
+
|
|
1522
|
+
// Remote project: use SSH plugin
|
|
1523
|
+
if (project.remote) {
|
|
1524
|
+
const plugin = await getRemotePlugin()
|
|
1525
|
+
if (!plugin) {
|
|
1526
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
1530
|
+
if (!server) {
|
|
1531
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// Try active changes folder first
|
|
1535
|
+
try {
|
|
1536
|
+
const content = await plugin.readRemoteFile(server, proposalPath)
|
|
1537
|
+
return res.json({ success: true, data: { changeId, content } })
|
|
1538
|
+
} catch {
|
|
1539
|
+
// Fallback: try archive folder
|
|
1540
|
+
const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
|
|
1541
|
+
if (archiveFolderPath) {
|
|
1542
|
+
try {
|
|
1543
|
+
const content = await plugin.readRemoteFile(server, `${archiveFolderPath}/proposal.md`)
|
|
1544
|
+
return res.json({ success: true, data: { changeId, content } })
|
|
1545
|
+
} catch {
|
|
1546
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Local project: use filesystem
|
|
1554
|
+
try {
|
|
1555
|
+
const content = await readFile(proposalPath, 'utf-8')
|
|
1556
|
+
res.json({ success: true, data: { changeId, content } })
|
|
1557
|
+
} catch {
|
|
1558
|
+
// Fallback: try archive folder
|
|
1559
|
+
const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId, 'proposal.md')
|
|
1560
|
+
try {
|
|
1561
|
+
const content = await readFile(archivePath, 'utf-8')
|
|
1562
|
+
res.json({ success: true, data: { changeId, content } })
|
|
1563
|
+
} catch {
|
|
1564
|
+
res.json({ success: true, data: { changeId, content: null } })
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
console.error('Error reading proposal:', error)
|
|
1569
|
+
res.status(500).json({ success: false, error: 'Failed to read proposal' })
|
|
1570
|
+
}
|
|
1571
|
+
})
|
|
1572
|
+
|
|
1573
|
+
// GET /changes/:id/design - Change의 design.md 내용
|
|
1574
|
+
flowRouter.get('/changes/:id/design', async (req, res) => {
|
|
1575
|
+
try {
|
|
1576
|
+
const changeId = req.params.id
|
|
1577
|
+
const project = await getProjectForChange(changeId)
|
|
1578
|
+
|
|
1579
|
+
if (!project) {
|
|
1580
|
+
return res.status(400).json({ success: false, error: 'Project not found for change' })
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const designPath = `${project.path}/openspec/changes/${changeId}/design.md`
|
|
1584
|
+
const archiveDir = `${project.path}/openspec/changes/archive`
|
|
1585
|
+
|
|
1586
|
+
// Remote project: use SSH plugin
|
|
1587
|
+
if (project.remote) {
|
|
1588
|
+
const plugin = await getRemotePlugin()
|
|
1589
|
+
if (!plugin) {
|
|
1590
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
1594
|
+
if (!server) {
|
|
1595
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Try active changes folder first
|
|
1599
|
+
try {
|
|
1600
|
+
const content = await plugin.readRemoteFile(server, designPath)
|
|
1601
|
+
return res.json({ success: true, data: { changeId, content } })
|
|
1602
|
+
} catch {
|
|
1603
|
+
// Fallback: try archive folder
|
|
1604
|
+
const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
|
|
1605
|
+
if (archiveFolderPath) {
|
|
1606
|
+
try {
|
|
1607
|
+
const content = await plugin.readRemoteFile(server, `${archiveFolderPath}/design.md`)
|
|
1608
|
+
return res.json({ success: true, data: { changeId, content } })
|
|
1609
|
+
} catch {
|
|
1610
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return res.json({ success: true, data: { changeId, content: null } })
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Local project: use filesystem
|
|
1618
|
+
try {
|
|
1619
|
+
const content = await readFile(designPath, 'utf-8')
|
|
1620
|
+
res.json({ success: true, data: { changeId, content } })
|
|
1621
|
+
} catch {
|
|
1622
|
+
// Fallback: try archive folder
|
|
1623
|
+
const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId, 'design.md')
|
|
1624
|
+
try {
|
|
1625
|
+
const content = await readFile(archivePath, 'utf-8')
|
|
1626
|
+
res.json({ success: true, data: { changeId, content } })
|
|
1627
|
+
} catch {
|
|
1628
|
+
res.json({ success: true, data: { changeId, content: null } })
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
console.error('Error reading design:', error)
|
|
1633
|
+
res.status(500).json({ success: false, error: 'Failed to read design' })
|
|
1634
|
+
}
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
// GET /changes/:id/spec - Change의 첫 번째 spec.md 내용
|
|
1638
|
+
flowRouter.get('/changes/:id/spec', async (req, res) => {
|
|
1639
|
+
try {
|
|
1640
|
+
const changeId = req.params.id
|
|
1641
|
+
const project = await getProjectForChange(changeId)
|
|
1642
|
+
|
|
1643
|
+
if (!project) {
|
|
1644
|
+
return res.status(400).json({ success: false, error: 'Project not found for change' })
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const specsDir = `${project.path}/openspec/changes/${changeId}/specs`
|
|
1648
|
+
const archiveDir = `${project.path}/openspec/changes/archive`
|
|
1649
|
+
|
|
1650
|
+
// Remote project: use SSH plugin
|
|
1651
|
+
if (project.remote) {
|
|
1652
|
+
const plugin = await getRemotePlugin()
|
|
1653
|
+
if (!plugin) {
|
|
1654
|
+
return res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
1658
|
+
if (!server) {
|
|
1659
|
+
return res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Try active changes folder first
|
|
1663
|
+
try {
|
|
1664
|
+
const listing = await plugin.listDirectory(server, specsDir)
|
|
1665
|
+
const specFolders = listing.entries
|
|
1666
|
+
.filter((e) => e.type === 'directory')
|
|
1667
|
+
.map((e) => e.name)
|
|
1668
|
+
|
|
1669
|
+
if (specFolders.length > 0) {
|
|
1670
|
+
const firstSpecId = specFolders[0]
|
|
1671
|
+
const specPath = `${specsDir}/${firstSpecId}/spec.md`
|
|
1672
|
+
const content = await plugin.readRemoteFile(server, specPath)
|
|
1673
|
+
return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
|
|
1674
|
+
}
|
|
1675
|
+
} catch {
|
|
1676
|
+
// Active folder not found, try archive
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Fallback: try archive folder
|
|
1680
|
+
const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
|
|
1681
|
+
if (archiveFolderPath) {
|
|
1682
|
+
try {
|
|
1683
|
+
const archiveSpecsDir = `${archiveFolderPath}/specs`
|
|
1684
|
+
const listing = await plugin.listDirectory(server, archiveSpecsDir)
|
|
1685
|
+
const specFolders = listing.entries
|
|
1686
|
+
.filter((e) => e.type === 'directory')
|
|
1687
|
+
.map((e) => e.name)
|
|
1688
|
+
|
|
1689
|
+
if (specFolders.length > 0) {
|
|
1690
|
+
const firstSpecId = specFolders[0]
|
|
1691
|
+
const specPath = `${archiveSpecsDir}/${firstSpecId}/spec.md`
|
|
1692
|
+
const content = await plugin.readRemoteFile(server, specPath)
|
|
1693
|
+
return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
|
|
1694
|
+
}
|
|
1695
|
+
} catch {
|
|
1696
|
+
// Archive specs not found
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// Local project: use filesystem
|
|
1704
|
+
try {
|
|
1705
|
+
const specFolders = await readdir(specsDir)
|
|
1706
|
+
if (specFolders.length > 0) {
|
|
1707
|
+
const firstSpecId = specFolders[0]
|
|
1708
|
+
const specPath = join(specsDir, firstSpecId, 'spec.md')
|
|
1709
|
+
const content = await readFile(specPath, 'utf-8')
|
|
1710
|
+
return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
|
|
1711
|
+
}
|
|
1712
|
+
} catch {
|
|
1713
|
+
// Active folder not found, try archive
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// Fallback: try archive folder
|
|
1717
|
+
try {
|
|
1718
|
+
const archiveSpecsDir = join(project.path, 'openspec', 'changes', 'archive', changeId, 'specs')
|
|
1719
|
+
const specFolders = await readdir(archiveSpecsDir)
|
|
1720
|
+
if (specFolders.length > 0) {
|
|
1721
|
+
const firstSpecId = specFolders[0]
|
|
1722
|
+
const specPath = join(archiveSpecsDir, firstSpecId, 'spec.md')
|
|
1723
|
+
const content = await readFile(specPath, 'utf-8')
|
|
1724
|
+
return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
|
|
1725
|
+
}
|
|
1726
|
+
} catch {
|
|
1727
|
+
// Archive specs not found
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
1731
|
+
} catch (error) {
|
|
1732
|
+
console.error('Error reading spec:', error)
|
|
1733
|
+
res.status(500).json({ success: false, error: 'Failed to read spec' })
|
|
1734
|
+
}
|
|
1735
|
+
})
|
|
1736
|
+
|
|
1737
|
+
// GET /changes/:changeId/specs/:specId - 특정 spec.md 내용
|
|
1738
|
+
flowRouter.get('/changes/:changeId/specs/:specId', async (req, res) => {
|
|
1739
|
+
try {
|
|
1740
|
+
const { changeId, specId } = req.params
|
|
1741
|
+
const project = await getProjectForChange(changeId)
|
|
1742
|
+
|
|
1743
|
+
if (!project) {
|
|
1744
|
+
return res.status(400).json({ success: false, error: 'Project not found for change' })
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
const changeSpecPath = `${project.path}/openspec/changes/${changeId}/specs/${specId}/spec.md`
|
|
1748
|
+
const archivedSpecPath = `${project.path}/openspec/specs/${specId}/spec.md`
|
|
1749
|
+
|
|
1750
|
+
// Remote project: use SSH plugin
|
|
1751
|
+
if (project.remote) {
|
|
1752
|
+
const plugin = await getRemotePlugin()
|
|
1753
|
+
if (!plugin) {
|
|
1754
|
+
return res.json({ success: true, data: { specId, content: null, location: null } })
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
1758
|
+
if (!server) {
|
|
1759
|
+
return res.json({ success: true, data: { specId, content: null, location: null } })
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Try change specs first
|
|
1763
|
+
try {
|
|
1764
|
+
const content = await plugin.readRemoteFile(server, changeSpecPath)
|
|
1765
|
+
return res.json({ success: true, data: { specId, content, location: 'change' } })
|
|
1766
|
+
} catch {
|
|
1767
|
+
// Not found in change, try archived
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Try archived specs
|
|
1771
|
+
try {
|
|
1772
|
+
const content = await plugin.readRemoteFile(server, archivedSpecPath)
|
|
1773
|
+
return res.json({ success: true, data: { specId, content, location: 'archived' } })
|
|
1774
|
+
} catch {
|
|
1775
|
+
return res.json({ success: true, data: { specId, content: null, location: null } })
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Local project: use filesystem
|
|
1780
|
+
try {
|
|
1781
|
+
const content = await readFile(changeSpecPath, 'utf-8')
|
|
1782
|
+
return res.json({ success: true, data: { specId, content, location: 'change' } })
|
|
1783
|
+
} catch {
|
|
1784
|
+
// Change 내에 없으면 archived specs에서 찾기
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
try {
|
|
1788
|
+
const content = await readFile(archivedSpecPath, 'utf-8')
|
|
1789
|
+
return res.json({ success: true, data: { specId, content, location: 'archived' } })
|
|
1790
|
+
} catch {
|
|
1791
|
+
return res.json({ success: true, data: { specId, content: null, location: null } })
|
|
1792
|
+
}
|
|
1793
|
+
} catch (error) {
|
|
1794
|
+
console.error('Error reading change spec:', error)
|
|
1795
|
+
res.status(500).json({ success: false, error: 'Failed to read spec' })
|
|
1796
|
+
}
|
|
1797
|
+
})
|
|
1798
|
+
|
|
1799
|
+
// PATCH /tasks/:id - Flow Task 수정
|
|
1800
|
+
flowRouter.patch('/tasks/:id', async (req, res) => {
|
|
1801
|
+
try {
|
|
1802
|
+
await initTaskDb()
|
|
1803
|
+
const { changeId, stage, title, description, status, priority, order } = req.body
|
|
1804
|
+
|
|
1805
|
+
const sqlite = getSqlite()
|
|
1806
|
+
const existing = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
|
|
1807
|
+
|
|
1808
|
+
if (!existing) {
|
|
1809
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
const updates: string[] = []
|
|
1813
|
+
const params: unknown[] = []
|
|
1814
|
+
|
|
1815
|
+
if (changeId !== undefined) {
|
|
1816
|
+
updates.push('change_id = ?')
|
|
1817
|
+
params.push(changeId)
|
|
1818
|
+
}
|
|
1819
|
+
if (stage !== undefined) {
|
|
1820
|
+
updates.push('stage = ?')
|
|
1821
|
+
params.push(stage)
|
|
1822
|
+
}
|
|
1823
|
+
if (title !== undefined) {
|
|
1824
|
+
updates.push('title = ?')
|
|
1825
|
+
params.push(title)
|
|
1826
|
+
}
|
|
1827
|
+
if (description !== undefined) {
|
|
1828
|
+
updates.push('description = ?')
|
|
1829
|
+
params.push(description)
|
|
1830
|
+
}
|
|
1831
|
+
if (status !== undefined) {
|
|
1832
|
+
updates.push('status = ?')
|
|
1833
|
+
params.push(status)
|
|
1834
|
+
}
|
|
1835
|
+
if (priority !== undefined) {
|
|
1836
|
+
updates.push('priority = ?')
|
|
1837
|
+
params.push(priority)
|
|
1838
|
+
}
|
|
1839
|
+
if (order !== undefined) {
|
|
1840
|
+
updates.push('"order" = ?')
|
|
1841
|
+
params.push(order)
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
updates.push('updated_at = ?')
|
|
1845
|
+
params.push(Date.now())
|
|
1846
|
+
params.push(req.params.id)
|
|
1847
|
+
|
|
1848
|
+
sqlite.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params)
|
|
1849
|
+
|
|
1850
|
+
const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
|
|
1851
|
+
|
|
1852
|
+
emit('task:updated', { task })
|
|
1853
|
+
|
|
1854
|
+
res.json({ success: true, data: { task } })
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
console.error('Error updating flow task:', error)
|
|
1857
|
+
res.status(500).json({ success: false, error: 'Failed to update flow task' })
|
|
1858
|
+
}
|
|
1859
|
+
})
|
|
1860
|
+
|
|
1861
|
+
// POST /changes/:id/sync - 특정 Change의 tasks.md를 DB에 수동 동기화
|
|
1862
|
+
flowRouter.post('/changes/:id/sync', async (req, res) => {
|
|
1863
|
+
try {
|
|
1864
|
+
await initTaskDb()
|
|
1865
|
+
const changeId = req.params.id
|
|
1866
|
+
const project = await getActiveProject()
|
|
1867
|
+
|
|
1868
|
+
if (!project) {
|
|
1869
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
let result: { tasksCreated: number; tasksUpdated: number }
|
|
1873
|
+
|
|
1874
|
+
// 원격 프로젝트인 경우 SSH를 통해 동기화
|
|
1875
|
+
if (project.remote?.serverId) {
|
|
1876
|
+
const plugin = await getRemotePlugin()
|
|
1877
|
+
if (!plugin) {
|
|
1878
|
+
return res.status(400).json({ success: false, error: 'Remote plugin not installed' })
|
|
1879
|
+
}
|
|
1880
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
1881
|
+
if (!server) {
|
|
1882
|
+
return res.status(400).json({ success: false, error: 'Remote server not found' })
|
|
1883
|
+
}
|
|
1884
|
+
result = await syncRemoteChangeTasksForProject(changeId, project.path, server, project.id)
|
|
1885
|
+
} else {
|
|
1886
|
+
// 로컬 프로젝트
|
|
1887
|
+
result = await syncChangeTasksFromFile(changeId)
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
emit('change:synced', {
|
|
1891
|
+
changeId,
|
|
1892
|
+
projectPath: project.path,
|
|
1893
|
+
tasksCreated: result.tasksCreated,
|
|
1894
|
+
tasksUpdated: result.tasksUpdated,
|
|
1895
|
+
})
|
|
1896
|
+
|
|
1897
|
+
res.json({
|
|
1898
|
+
success: true,
|
|
1899
|
+
data: {
|
|
1900
|
+
changeId,
|
|
1901
|
+
tasksCreated: result.tasksCreated,
|
|
1902
|
+
tasksUpdated: result.tasksUpdated,
|
|
1903
|
+
},
|
|
1904
|
+
})
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
console.error('Error syncing change:', error)
|
|
1907
|
+
res.status(500).json({
|
|
1908
|
+
success: false,
|
|
1909
|
+
error: error instanceof Error ? error.message : 'Failed to sync change',
|
|
1910
|
+
})
|
|
1911
|
+
}
|
|
1912
|
+
})
|
|
1913
|
+
|
|
1914
|
+
// POST /sync/all - 모든 프로젝트의 모든 Changes 동기화
|
|
1915
|
+
flowRouter.post('/sync/all', async (_req, res) => {
|
|
1916
|
+
try {
|
|
1917
|
+
await initTaskDb()
|
|
1918
|
+
const config = await loadConfig()
|
|
1919
|
+
|
|
1920
|
+
if (config.projects.length === 0) {
|
|
1921
|
+
return res.json({
|
|
1922
|
+
success: true,
|
|
1923
|
+
data: { synced: 0, created: 0, updated: 0, projects: 0 },
|
|
1924
|
+
})
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
let totalCreated = 0
|
|
1928
|
+
let totalUpdated = 0
|
|
1929
|
+
let projectsSynced = 0
|
|
1930
|
+
|
|
1931
|
+
for (const project of config.projects) {
|
|
1932
|
+
const openspecDir = join(project.path, 'openspec', 'changes')
|
|
1933
|
+
let entries
|
|
1934
|
+
try {
|
|
1935
|
+
entries = await readdir(openspecDir, { withFileTypes: true })
|
|
1936
|
+
} catch {
|
|
1937
|
+
continue
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
projectsSynced++
|
|
1941
|
+
|
|
1942
|
+
for (const entry of entries) {
|
|
1943
|
+
if (!entry.isDirectory() || entry.name === 'archive') continue
|
|
1944
|
+
|
|
1945
|
+
const changeId = entry.name
|
|
1946
|
+
try {
|
|
1947
|
+
const result = await syncChangeTasksForProject(changeId, project.path, project.id)
|
|
1948
|
+
totalCreated += result.tasksCreated
|
|
1949
|
+
totalUpdated += result.tasksUpdated
|
|
1950
|
+
} catch (syncError) {
|
|
1951
|
+
console.error(`Error syncing ${changeId}:`, syncError)
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
emit('sync:completed', {
|
|
1957
|
+
totalCreated,
|
|
1958
|
+
totalUpdated,
|
|
1959
|
+
projectsSynced,
|
|
1960
|
+
})
|
|
1961
|
+
|
|
1962
|
+
res.json({
|
|
1963
|
+
success: true,
|
|
1964
|
+
data: {
|
|
1965
|
+
synced: totalCreated + totalUpdated,
|
|
1966
|
+
created: totalCreated,
|
|
1967
|
+
updated: totalUpdated,
|
|
1968
|
+
projects: projectsSynced,
|
|
1969
|
+
},
|
|
1970
|
+
})
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
console.error('Error syncing all changes:', error)
|
|
1973
|
+
res.status(500).json({
|
|
1974
|
+
success: false,
|
|
1975
|
+
error: error instanceof Error ? error.message : 'Failed to sync all changes',
|
|
1976
|
+
})
|
|
1977
|
+
}
|
|
1978
|
+
})
|
|
1979
|
+
|
|
1980
|
+
// POST /changes/:id/archive - Change를 아카이브로 이동
|
|
1981
|
+
flowRouter.post('/changes/:id/archive', async (req, res) => {
|
|
1982
|
+
try {
|
|
1983
|
+
const changeId = req.params.id
|
|
1984
|
+
const { skipSpecs, force, projectId } = req.body
|
|
1985
|
+
|
|
1986
|
+
// projectId가 있으면 해당 프로젝트 사용, 없으면 활성 프로젝트
|
|
1987
|
+
const project = projectId ? await getProjectById(projectId) : await getActiveProject()
|
|
1988
|
+
|
|
1989
|
+
if (!project) {
|
|
1990
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
const args = ['archive', changeId, '-y']
|
|
1994
|
+
if (skipSpecs) {
|
|
1995
|
+
args.push('--skip-specs')
|
|
1996
|
+
}
|
|
1997
|
+
if (force) {
|
|
1998
|
+
args.push('--no-validate')
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
let stdout = ''
|
|
2002
|
+
let stderr = ''
|
|
2003
|
+
let validationFailed = false
|
|
2004
|
+
const validationErrors: string[] = []
|
|
2005
|
+
let filesMoved = false
|
|
2006
|
+
|
|
2007
|
+
// 원격 프로젝트인 경우 SSH를 통해 아카이브 실행
|
|
2008
|
+
if (project.remote) {
|
|
2009
|
+
const plugin = await getRemotePlugin()
|
|
2010
|
+
if (!plugin) {
|
|
2011
|
+
return res.status(400).json({ success: false, error: 'Remote plugin not installed' })
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const server = await plugin.getRemoteServerById(project.remote.serverId)
|
|
2015
|
+
if (!server) {
|
|
2016
|
+
return res.status(400).json({ success: false, error: 'Remote server not found' })
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Remote plugin functions
|
|
2020
|
+
const executeCommand = (plugin as unknown as { executeCommand: (s: unknown, cmd: string) => Promise<{ stdout: string }> }).executeCommand
|
|
2021
|
+
const exists = async (s: unknown, p: string) => {
|
|
2022
|
+
try {
|
|
2023
|
+
await executeCommand(s, `test -e "${p}"`)
|
|
2024
|
+
return true
|
|
2025
|
+
} catch {
|
|
2026
|
+
return false
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
// 원격에서 openspec archive 명령 실행 또는 직접 mv 명령 실행
|
|
2031
|
+
const archivePath = `${project.path}/openspec/changes/archive`
|
|
2032
|
+
const sourcePath = `${project.path}/openspec/changes/${changeId}`
|
|
2033
|
+
const targetPath = `${archivePath}/${new Date().toISOString().split('T')[0]}-${changeId}`
|
|
2034
|
+
|
|
2035
|
+
try {
|
|
2036
|
+
// 먼저 archive 폴더 존재 확인
|
|
2037
|
+
const archiveExists = await exists(server, archivePath)
|
|
2038
|
+
if (!archiveExists) {
|
|
2039
|
+
await executeCommand(server, `mkdir -p "${archivePath}"`, { cwd: project.path })
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// 소스 폴더 존재 확인
|
|
2043
|
+
const sourceExists = await exists(server, sourcePath)
|
|
2044
|
+
if (!sourceExists) {
|
|
2045
|
+
return res.status(404).json({
|
|
2046
|
+
success: false,
|
|
2047
|
+
error: `Change folder not found: ${changeId}`,
|
|
2048
|
+
})
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// mv 명령으로 아카이브 폴더로 이동
|
|
2052
|
+
const mvResult = await executeCommand(server, `mv "${sourcePath}" "${targetPath}"`, {
|
|
2053
|
+
cwd: project.path,
|
|
2054
|
+
})
|
|
2055
|
+
|
|
2056
|
+
stdout = mvResult.stdout
|
|
2057
|
+
stderr = mvResult.stderr
|
|
2058
|
+
|
|
2059
|
+
if (mvResult.exitCode === 0) {
|
|
2060
|
+
filesMoved = true
|
|
2061
|
+
console.log(`[Archive] Remote archive successful: ${changeId} -> ${targetPath}`)
|
|
2062
|
+
} else {
|
|
2063
|
+
throw new Error(`Failed to move change folder: ${stderr}`)
|
|
2064
|
+
}
|
|
2065
|
+
} catch (execError) {
|
|
2066
|
+
const error = execError as { stdout?: string; stderr?: string; message?: string }
|
|
2067
|
+
console.error('[Archive] Remote archive error:', error)
|
|
2068
|
+
throw new Error(error.message || 'Failed to archive change on remote server')
|
|
2069
|
+
}
|
|
2070
|
+
} else {
|
|
2071
|
+
// 로컬 프로젝트인 경우 기존 로직 사용
|
|
2072
|
+
try {
|
|
2073
|
+
const result = await execAsync(`openspec ${args.join(' ')}`, {
|
|
2074
|
+
cwd: project.path,
|
|
2075
|
+
})
|
|
2076
|
+
stdout = result.stdout
|
|
2077
|
+
stderr = result.stderr
|
|
2078
|
+
} catch (execError) {
|
|
2079
|
+
const error = execError as { stdout?: string; stderr?: string; message?: string }
|
|
2080
|
+
stdout = error.stdout || ''
|
|
2081
|
+
stderr = error.stderr || ''
|
|
2082
|
+
|
|
2083
|
+
if (stdout.includes('Validation failed') || stdout.includes('Validation errors')) {
|
|
2084
|
+
validationFailed = true
|
|
2085
|
+
const lines = stdout.split('\n')
|
|
2086
|
+
for (const line of lines) {
|
|
2087
|
+
if (line.includes('✗') || line.includes('⚠')) {
|
|
2088
|
+
validationErrors.push(line.trim())
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
} else {
|
|
2092
|
+
throw execError
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (validationFailed && !force) {
|
|
2097
|
+
console.log(`[Archive] Validation failed for ${changeId}, returning error to client`)
|
|
2098
|
+
return res.status(422).json({
|
|
2099
|
+
success: false,
|
|
2100
|
+
error: 'Validation failed',
|
|
2101
|
+
validationErrors,
|
|
2102
|
+
canForce: true,
|
|
2103
|
+
hint: 'Use force option to archive without validation, or fix the spec errors first',
|
|
2104
|
+
})
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId)
|
|
2108
|
+
const originalPath = join(project.path, 'openspec', 'changes', changeId)
|
|
2109
|
+
|
|
2110
|
+
try {
|
|
2111
|
+
await access(archivePath)
|
|
2112
|
+
filesMoved = true
|
|
2113
|
+
} catch {
|
|
2114
|
+
try {
|
|
2115
|
+
await access(originalPath)
|
|
2116
|
+
filesMoved = false
|
|
2117
|
+
} catch {
|
|
2118
|
+
filesMoved = false
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// DB 업데이트 (로컬/원격 공통)
|
|
2124
|
+
await initTaskDb()
|
|
2125
|
+
const sqlite = getSqlite()
|
|
2126
|
+
const now = Date.now()
|
|
2127
|
+
|
|
2128
|
+
sqlite
|
|
2129
|
+
.prepare(
|
|
2130
|
+
`
|
|
2131
|
+
UPDATE changes SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ? AND project_id = ?
|
|
2132
|
+
`
|
|
2133
|
+
)
|
|
2134
|
+
.run(now, now, changeId, project.id)
|
|
2135
|
+
|
|
2136
|
+
// tasks도 archived로 업데이트
|
|
2137
|
+
sqlite
|
|
2138
|
+
.prepare(
|
|
2139
|
+
`
|
|
2140
|
+
UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE change_id = ? AND project_id = ?
|
|
2141
|
+
`
|
|
2142
|
+
)
|
|
2143
|
+
.run(now, now, changeId, project.id)
|
|
2144
|
+
|
|
2145
|
+
emit('change:archived', { changeId, projectId: project.id })
|
|
2146
|
+
|
|
2147
|
+
res.json({
|
|
2148
|
+
success: true,
|
|
2149
|
+
data: {
|
|
2150
|
+
changeId,
|
|
2151
|
+
archived: true,
|
|
2152
|
+
filesMoved,
|
|
2153
|
+
stdout: stdout.trim(),
|
|
2154
|
+
stderr: stderr.trim(),
|
|
2155
|
+
},
|
|
2156
|
+
})
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
console.error('Error archiving change:', error)
|
|
2159
|
+
res.status(500).json({
|
|
2160
|
+
success: false,
|
|
2161
|
+
error: error instanceof Error ? error.message : 'Failed to archive change',
|
|
2162
|
+
})
|
|
2163
|
+
}
|
|
2164
|
+
})
|
|
2165
|
+
|
|
2166
|
+
// POST /changes/:id/fix-validation - 자동으로 validation 에러 수정
|
|
2167
|
+
flowRouter.post('/changes/:id/fix-validation', async (req, res) => {
|
|
2168
|
+
try {
|
|
2169
|
+
const changeId = req.params.id
|
|
2170
|
+
const project = await getActiveProject()
|
|
2171
|
+
|
|
2172
|
+
if (!project) {
|
|
2173
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
const changeDir = join(project.path, 'openspec', 'changes', changeId)
|
|
2177
|
+
const fs = await import('fs/promises')
|
|
2178
|
+
|
|
2179
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
2180
|
+
let proposalFixed = false
|
|
2181
|
+
try {
|
|
2182
|
+
const content = await fs.readFile(proposalPath, 'utf-8')
|
|
2183
|
+
const lines = content.split('\n')
|
|
2184
|
+
const fixedLines = lines.map((line) => {
|
|
2185
|
+
if (line.match(/^[-*]\s+/) && !line.match(/\b(SHALL|MUST)\b/i)) {
|
|
2186
|
+
if (line.match(/^[-*]\s+\w/)) {
|
|
2187
|
+
return line.replace(/^([-*]\s+)/, '$1The system SHALL ')
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
return line
|
|
2191
|
+
})
|
|
2192
|
+
const newContent = fixedLines.join('\n')
|
|
2193
|
+
if (newContent !== content) {
|
|
2194
|
+
await fs.writeFile(proposalPath, newContent)
|
|
2195
|
+
proposalFixed = true
|
|
2196
|
+
}
|
|
2197
|
+
} catch {
|
|
2198
|
+
// proposal.md not found or not readable
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const specsDir = join(changeDir, 'specs')
|
|
2202
|
+
let specsFixed = 0
|
|
2203
|
+
try {
|
|
2204
|
+
const specEntries = await fs.readdir(specsDir, { withFileTypes: true })
|
|
2205
|
+
for (const entry of specEntries) {
|
|
2206
|
+
if (!entry.isDirectory()) continue
|
|
2207
|
+
const specPath = join(specsDir, entry.name, 'spec.md')
|
|
2208
|
+
try {
|
|
2209
|
+
const content = await fs.readFile(specPath, 'utf-8')
|
|
2210
|
+
const lines = content.split('\n')
|
|
2211
|
+
const fixedLines = lines.map((line) => {
|
|
2212
|
+
if (line.match(/^###\s+Requirement:/)) {
|
|
2213
|
+
if (!line.match(/\b(SHALL|MUST)\b/i)) {
|
|
2214
|
+
return line.replace(/(###\s+Requirement:\s*)(.+)/, (_, prefix, reqText) => {
|
|
2215
|
+
return `${prefix}The system SHALL provide ${reqText}`
|
|
2216
|
+
})
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
return line
|
|
2220
|
+
})
|
|
2221
|
+
const newContent = fixedLines.join('\n')
|
|
2222
|
+
if (newContent !== content) {
|
|
2223
|
+
await fs.writeFile(specPath, newContent)
|
|
2224
|
+
specsFixed++
|
|
2225
|
+
}
|
|
2226
|
+
} catch {
|
|
2227
|
+
// spec.md not found
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
// specs directory not found
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
res.json({
|
|
2235
|
+
success: true,
|
|
2236
|
+
data: {
|
|
2237
|
+
changeId,
|
|
2238
|
+
proposalFixed,
|
|
2239
|
+
specsFixed,
|
|
2240
|
+
message:
|
|
2241
|
+
proposalFixed || specsFixed > 0
|
|
2242
|
+
? `Fixed validation errors: proposal=${proposalFixed}, specs=${specsFixed}`
|
|
2243
|
+
: 'No fixes needed or possible',
|
|
2244
|
+
},
|
|
2245
|
+
})
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
console.error('Error fixing validation:', error)
|
|
2248
|
+
res.status(500).json({
|
|
2249
|
+
success: false,
|
|
2250
|
+
error: error instanceof Error ? error.message : 'Failed to fix validation',
|
|
2251
|
+
})
|
|
2252
|
+
}
|
|
2253
|
+
})
|
|
2254
|
+
|
|
2255
|
+
// =============================================
|
|
2256
|
+
// Backlog API 엔드포인트
|
|
2257
|
+
// =============================================
|
|
2258
|
+
|
|
2259
|
+
// POST /backlog/sync - Backlog 파일을 DB에 동기화
|
|
2260
|
+
flowRouter.post('/backlog/sync', async (_req, res) => {
|
|
2261
|
+
try {
|
|
2262
|
+
await initTaskDb()
|
|
2263
|
+
const project = await getActiveProject()
|
|
2264
|
+
|
|
2265
|
+
if (!project) {
|
|
2266
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
const result = await syncBacklogToDb(project.id, project.path)
|
|
2270
|
+
|
|
2271
|
+
emit('backlog:synced', {
|
|
2272
|
+
projectId: project.id,
|
|
2273
|
+
...result,
|
|
2274
|
+
})
|
|
2275
|
+
|
|
2276
|
+
res.json({
|
|
2277
|
+
success: true,
|
|
2278
|
+
data: result,
|
|
2279
|
+
})
|
|
2280
|
+
} catch (error) {
|
|
2281
|
+
console.error('Error syncing backlog:', error)
|
|
2282
|
+
res.status(500).json({
|
|
2283
|
+
success: false,
|
|
2284
|
+
error: error instanceof Error ? error.message : 'Failed to sync backlog',
|
|
2285
|
+
})
|
|
2286
|
+
}
|
|
2287
|
+
})
|
|
2288
|
+
|
|
2289
|
+
// POST /backlog/tasks - Backlog 태스크 생성 (마크다운 파일 생성)
|
|
2290
|
+
flowRouter.post('/backlog/tasks', async (req, res) => {
|
|
2291
|
+
try {
|
|
2292
|
+
await initTaskDb()
|
|
2293
|
+
const project = await getActiveProject()
|
|
2294
|
+
|
|
2295
|
+
if (!project) {
|
|
2296
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
const {
|
|
2300
|
+
title,
|
|
2301
|
+
description,
|
|
2302
|
+
status = 'todo',
|
|
2303
|
+
priority = 'medium',
|
|
2304
|
+
assignees,
|
|
2305
|
+
labels,
|
|
2306
|
+
blockedBy,
|
|
2307
|
+
parent,
|
|
2308
|
+
dueDate,
|
|
2309
|
+
milestone,
|
|
2310
|
+
plan,
|
|
2311
|
+
acceptanceCriteria,
|
|
2312
|
+
notes,
|
|
2313
|
+
} = req.body
|
|
2314
|
+
|
|
2315
|
+
if (!title) {
|
|
2316
|
+
return res.status(400).json({ success: false, error: 'Title is required' })
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
// 새 backlogFileId 생성
|
|
2320
|
+
const backlogFileId = await generateNewBacklogTaskId(project.path)
|
|
2321
|
+
|
|
2322
|
+
// BacklogTask 객체 생성
|
|
2323
|
+
const task: BacklogTask = {
|
|
2324
|
+
backlogFileId,
|
|
2325
|
+
title,
|
|
2326
|
+
description,
|
|
2327
|
+
status,
|
|
2328
|
+
priority,
|
|
2329
|
+
assignees,
|
|
2330
|
+
labels,
|
|
2331
|
+
blockedBy,
|
|
2332
|
+
parent,
|
|
2333
|
+
dueDate,
|
|
2334
|
+
milestone,
|
|
2335
|
+
plan,
|
|
2336
|
+
acceptanceCriteria,
|
|
2337
|
+
notes,
|
|
2338
|
+
filePath: '', // saveTaskToBacklogFile에서 설정됨
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// 마크다운 파일 저장
|
|
2342
|
+
const filePath = await saveTaskToBacklogFile(project.path, task)
|
|
2343
|
+
task.filePath = filePath
|
|
2344
|
+
|
|
2345
|
+
// DB에 동기화
|
|
2346
|
+
const result = await syncBacklogToDb(project.id, project.path)
|
|
2347
|
+
|
|
2348
|
+
emit('backlog:task:created', {
|
|
2349
|
+
projectId: project.id,
|
|
2350
|
+
backlogFileId,
|
|
2351
|
+
filePath,
|
|
2352
|
+
})
|
|
2353
|
+
|
|
2354
|
+
res.json({
|
|
2355
|
+
success: true,
|
|
2356
|
+
data: {
|
|
2357
|
+
backlogFileId,
|
|
2358
|
+
filePath,
|
|
2359
|
+
synced: result,
|
|
2360
|
+
},
|
|
2361
|
+
})
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
console.error('Error creating backlog task:', error)
|
|
2364
|
+
res.status(500).json({
|
|
2365
|
+
success: false,
|
|
2366
|
+
error: error instanceof Error ? error.message : 'Failed to create backlog task',
|
|
2367
|
+
})
|
|
2368
|
+
}
|
|
2369
|
+
})
|
|
2370
|
+
|
|
2371
|
+
// PATCH /backlog/tasks/:backlogFileId - Backlog 태스크 수정 (마크다운 파일 수정)
|
|
2372
|
+
flowRouter.patch('/backlog/tasks/:backlogFileId', async (req, res) => {
|
|
2373
|
+
try {
|
|
2374
|
+
await initTaskDb()
|
|
2375
|
+
const project = await getActiveProject()
|
|
2376
|
+
|
|
2377
|
+
if (!project) {
|
|
2378
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
const { backlogFileId } = req.params
|
|
2382
|
+
const updates = req.body
|
|
2383
|
+
|
|
2384
|
+
// 기존 태스크 조회
|
|
2385
|
+
const sqlite = getSqlite()
|
|
2386
|
+
const existing = sqlite
|
|
2387
|
+
.prepare(
|
|
2388
|
+
`
|
|
2389
|
+
SELECT * FROM tasks
|
|
2390
|
+
WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
|
|
2391
|
+
`
|
|
2392
|
+
)
|
|
2393
|
+
.get(project.id, backlogFileId) as
|
|
2394
|
+
| {
|
|
2395
|
+
id: number
|
|
2396
|
+
title: string
|
|
2397
|
+
description: string | null
|
|
2398
|
+
status: string
|
|
2399
|
+
priority: string
|
|
2400
|
+
tags: string | null
|
|
2401
|
+
assignee: string | null
|
|
2402
|
+
parent_task_id: number | null
|
|
2403
|
+
blocked_by: string | null
|
|
2404
|
+
plan: string | null
|
|
2405
|
+
acceptance_criteria: string | null
|
|
2406
|
+
notes: string | null
|
|
2407
|
+
due_date: number | null
|
|
2408
|
+
milestone: string | null
|
|
2409
|
+
backlog_file_id: string
|
|
2410
|
+
}
|
|
2411
|
+
| undefined
|
|
2412
|
+
|
|
2413
|
+
if (!existing) {
|
|
2414
|
+
return res.status(404).json({ success: false, error: 'Backlog task not found' })
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// BacklogTask 객체 생성 (기존 + 업데이트)
|
|
2418
|
+
const task: BacklogTask = {
|
|
2419
|
+
backlogFileId: existing.backlog_file_id,
|
|
2420
|
+
title: updates.title ?? existing.title,
|
|
2421
|
+
description: updates.description ?? existing.description ?? undefined,
|
|
2422
|
+
status: updates.status ?? existing.status,
|
|
2423
|
+
priority: updates.priority ?? existing.priority,
|
|
2424
|
+
assignees: updates.assignees ?? (existing.assignee ? [existing.assignee] : undefined),
|
|
2425
|
+
labels: updates.labels ?? (existing.tags ? JSON.parse(existing.tags) : undefined),
|
|
2426
|
+
blockedBy:
|
|
2427
|
+
updates.blockedBy ?? (existing.blocked_by ? JSON.parse(existing.blocked_by) : undefined),
|
|
2428
|
+
parent: updates.parent,
|
|
2429
|
+
dueDate:
|
|
2430
|
+
updates.dueDate ??
|
|
2431
|
+
(existing.due_date ? new Date(existing.due_date).toISOString().split('T')[0] : undefined),
|
|
2432
|
+
milestone: updates.milestone ?? existing.milestone ?? undefined,
|
|
2433
|
+
plan: updates.plan ?? existing.plan ?? undefined,
|
|
2434
|
+
acceptanceCriteria: updates.acceptanceCriteria ?? existing.acceptance_criteria ?? undefined,
|
|
2435
|
+
notes: updates.notes ?? existing.notes ?? undefined,
|
|
2436
|
+
filePath: '',
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// 마크다운 파일 저장
|
|
2440
|
+
const filePath = await saveTaskToBacklogFile(project.path, task)
|
|
2441
|
+
|
|
2442
|
+
// DB에 동기화
|
|
2443
|
+
const result = await syncBacklogToDb(project.id, project.path)
|
|
2444
|
+
|
|
2445
|
+
emit('backlog:task:updated', {
|
|
2446
|
+
projectId: project.id,
|
|
2447
|
+
backlogFileId,
|
|
2448
|
+
filePath,
|
|
2449
|
+
})
|
|
2450
|
+
|
|
2451
|
+
res.json({
|
|
2452
|
+
success: true,
|
|
2453
|
+
data: {
|
|
2454
|
+
backlogFileId,
|
|
2455
|
+
filePath,
|
|
2456
|
+
synced: result,
|
|
2457
|
+
},
|
|
2458
|
+
})
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
console.error('Error updating backlog task:', error)
|
|
2461
|
+
res.status(500).json({
|
|
2462
|
+
success: false,
|
|
2463
|
+
error: error instanceof Error ? error.message : 'Failed to update backlog task',
|
|
2464
|
+
})
|
|
2465
|
+
}
|
|
2466
|
+
})
|
|
2467
|
+
|
|
2468
|
+
// DELETE /backlog/tasks/:backlogFileId - Backlog 태스크 삭제 (마크다운 파일 삭제)
|
|
2469
|
+
flowRouter.delete('/backlog/tasks/:backlogFileId', async (req, res) => {
|
|
2470
|
+
try {
|
|
2471
|
+
await initTaskDb()
|
|
2472
|
+
const project = await getActiveProject()
|
|
2473
|
+
|
|
2474
|
+
if (!project) {
|
|
2475
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
const { backlogFileId } = req.params
|
|
2479
|
+
const { archive = false } = req.query
|
|
2480
|
+
|
|
2481
|
+
// 기존 태스크 조회
|
|
2482
|
+
const sqlite = getSqlite()
|
|
2483
|
+
const existing = sqlite
|
|
2484
|
+
.prepare(
|
|
2485
|
+
`
|
|
2486
|
+
SELECT id, title FROM tasks
|
|
2487
|
+
WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
|
|
2488
|
+
`
|
|
2489
|
+
)
|
|
2490
|
+
.get(project.id, backlogFileId) as { id: number; title: string } | undefined
|
|
2491
|
+
|
|
2492
|
+
if (!existing) {
|
|
2493
|
+
return res.status(404).json({ success: false, error: 'Backlog task not found' })
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
const backlogPath = getBacklogPath(project.path)
|
|
2497
|
+
const filename = generateBacklogFilename(backlogFileId, existing.title)
|
|
2498
|
+
const filePath = join(backlogPath, filename)
|
|
2499
|
+
|
|
2500
|
+
if (archive === 'true') {
|
|
2501
|
+
// 아카이브 폴더로 이동
|
|
2502
|
+
const archivePath = join(backlogPath, 'archive')
|
|
2503
|
+
await ensureBacklogDir(
|
|
2504
|
+
join(project.path, 'backlog', 'archive').replace('/backlog/archive', '')
|
|
2505
|
+
)
|
|
2506
|
+
try {
|
|
2507
|
+
await access(archivePath)
|
|
2508
|
+
} catch {
|
|
2509
|
+
const { mkdir } = await import('fs/promises')
|
|
2510
|
+
await mkdir(archivePath, { recursive: true })
|
|
2511
|
+
}
|
|
2512
|
+
await rename(filePath, join(archivePath, filename))
|
|
2513
|
+
} else {
|
|
2514
|
+
// 파일 삭제
|
|
2515
|
+
await unlink(filePath)
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// DB에 동기화 (삭제된 파일은 archived로 처리됨)
|
|
2519
|
+
const result = await syncBacklogToDb(project.id, project.path)
|
|
2520
|
+
|
|
2521
|
+
emit('backlog:task:deleted', {
|
|
2522
|
+
projectId: project.id,
|
|
2523
|
+
backlogFileId,
|
|
2524
|
+
archived: archive === 'true',
|
|
2525
|
+
})
|
|
2526
|
+
|
|
2527
|
+
res.json({
|
|
2528
|
+
success: true,
|
|
2529
|
+
data: {
|
|
2530
|
+
backlogFileId,
|
|
2531
|
+
deleted: true,
|
|
2532
|
+
archived: archive === 'true',
|
|
2533
|
+
synced: result,
|
|
2534
|
+
},
|
|
2535
|
+
})
|
|
2536
|
+
} catch (error) {
|
|
2537
|
+
console.error('Error deleting backlog task:', error)
|
|
2538
|
+
res.status(500).json({
|
|
2539
|
+
success: false,
|
|
2540
|
+
error: error instanceof Error ? error.message : 'Failed to delete backlog task',
|
|
2541
|
+
})
|
|
2542
|
+
}
|
|
2543
|
+
})
|
|
2544
|
+
|
|
2545
|
+
// GET /backlog/tasks/:backlogFileId - Backlog 태스크 상세 조회
|
|
2546
|
+
flowRouter.get('/backlog/tasks/:backlogFileId', async (req, res) => {
|
|
2547
|
+
try {
|
|
2548
|
+
await initTaskDb()
|
|
2549
|
+
const project = await getActiveProject()
|
|
2550
|
+
|
|
2551
|
+
if (!project) {
|
|
2552
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
const { backlogFileId } = req.params
|
|
2556
|
+
|
|
2557
|
+
const sqlite = getSqlite()
|
|
2558
|
+
const task = sqlite
|
|
2559
|
+
.prepare(
|
|
2560
|
+
`
|
|
2561
|
+
SELECT * FROM tasks
|
|
2562
|
+
WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
|
|
2563
|
+
`
|
|
2564
|
+
)
|
|
2565
|
+
.get(project.id, backlogFileId) as
|
|
2566
|
+
| {
|
|
2567
|
+
id: number
|
|
2568
|
+
change_id: string | null
|
|
2569
|
+
stage: Stage
|
|
2570
|
+
origin: TaskOrigin | null
|
|
2571
|
+
title: string
|
|
2572
|
+
description: string | null
|
|
2573
|
+
status: string
|
|
2574
|
+
priority: string
|
|
2575
|
+
tags: string | null
|
|
2576
|
+
assignee: string | null
|
|
2577
|
+
order: number
|
|
2578
|
+
parent_task_id: number | null
|
|
2579
|
+
blocked_by: string | null
|
|
2580
|
+
plan: string | null
|
|
2581
|
+
acceptance_criteria: string | null
|
|
2582
|
+
notes: string | null
|
|
2583
|
+
due_date: number | null
|
|
2584
|
+
milestone: string | null
|
|
2585
|
+
backlog_file_id: string | null
|
|
2586
|
+
created_at: number
|
|
2587
|
+
updated_at: number
|
|
2588
|
+
archived_at: number | null
|
|
2589
|
+
}
|
|
2590
|
+
| undefined
|
|
2591
|
+
|
|
2592
|
+
if (!task) {
|
|
2593
|
+
return res.status(404).json({ success: false, error: 'Backlog task not found' })
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// 서브태스크 조회
|
|
2597
|
+
const subtasks = sqlite
|
|
2598
|
+
.prepare(
|
|
2599
|
+
`
|
|
2600
|
+
SELECT id, title, status, priority FROM tasks
|
|
2601
|
+
WHERE project_id = ? AND parent_task_id = ? AND origin = 'backlog' AND status != 'archived'
|
|
2602
|
+
`
|
|
2603
|
+
)
|
|
2604
|
+
.all(project.id, task.id) as Array<{
|
|
2605
|
+
id: number
|
|
2606
|
+
title: string
|
|
2607
|
+
status: string
|
|
2608
|
+
priority: string
|
|
2609
|
+
}>
|
|
2610
|
+
|
|
2611
|
+
const formatted = {
|
|
2612
|
+
id: task.id,
|
|
2613
|
+
changeId: task.change_id,
|
|
2614
|
+
stage: task.stage,
|
|
2615
|
+
origin: task.origin,
|
|
2616
|
+
title: task.title,
|
|
2617
|
+
description: task.description,
|
|
2618
|
+
status: task.status,
|
|
2619
|
+
priority: task.priority,
|
|
2620
|
+
tags: task.tags ? JSON.parse(task.tags) : [],
|
|
2621
|
+
assignee: task.assignee,
|
|
2622
|
+
order: task.order,
|
|
2623
|
+
parentTaskId: task.parent_task_id,
|
|
2624
|
+
blockedBy: task.blocked_by ? JSON.parse(task.blocked_by) : null,
|
|
2625
|
+
plan: task.plan,
|
|
2626
|
+
acceptanceCriteria: task.acceptance_criteria,
|
|
2627
|
+
notes: task.notes,
|
|
2628
|
+
dueDate: task.due_date ? new Date(task.due_date).toISOString() : null,
|
|
2629
|
+
milestone: task.milestone,
|
|
2630
|
+
backlogFileId: task.backlog_file_id,
|
|
2631
|
+
subtasks,
|
|
2632
|
+
createdAt: new Date(task.created_at).toISOString(),
|
|
2633
|
+
updatedAt: new Date(task.updated_at).toISOString(),
|
|
2634
|
+
archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
res.json({ success: true, data: { task: formatted } })
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
console.error('Error getting backlog task:', error)
|
|
2640
|
+
res.status(500).json({ success: false, error: 'Failed to get backlog task' })
|
|
2641
|
+
}
|
|
2642
|
+
})
|
|
2643
|
+
|
|
2644
|
+
// GET /backlog/stats - Backlog 통계
|
|
2645
|
+
flowRouter.get('/backlog/stats', async (_req, res) => {
|
|
2646
|
+
try {
|
|
2647
|
+
await initTaskDb()
|
|
2648
|
+
const project = await getActiveProject()
|
|
2649
|
+
|
|
2650
|
+
if (!project) {
|
|
2651
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
const sqlite = getSqlite()
|
|
2655
|
+
|
|
2656
|
+
// 상태별 집계
|
|
2657
|
+
const byStatus = sqlite
|
|
2658
|
+
.prepare(
|
|
2659
|
+
`
|
|
2660
|
+
SELECT status, COUNT(*) as count
|
|
2661
|
+
FROM tasks
|
|
2662
|
+
WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
|
|
2663
|
+
GROUP BY status
|
|
2664
|
+
`
|
|
2665
|
+
)
|
|
2666
|
+
.all(project.id) as Array<{ status: string; count: number }>
|
|
2667
|
+
|
|
2668
|
+
// 우선순위별 집계
|
|
2669
|
+
const byPriority = sqlite
|
|
2670
|
+
.prepare(
|
|
2671
|
+
`
|
|
2672
|
+
SELECT priority, COUNT(*) as count
|
|
2673
|
+
FROM tasks
|
|
2674
|
+
WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
|
|
2675
|
+
GROUP BY priority
|
|
2676
|
+
`
|
|
2677
|
+
)
|
|
2678
|
+
.all(project.id) as Array<{ priority: string; count: number }>
|
|
2679
|
+
|
|
2680
|
+
// 마일스톤별 집계
|
|
2681
|
+
const byMilestone = sqlite
|
|
2682
|
+
.prepare(
|
|
2683
|
+
`
|
|
2684
|
+
SELECT milestone, COUNT(*) as count
|
|
2685
|
+
FROM tasks
|
|
2686
|
+
WHERE project_id = ? AND origin = 'backlog' AND status != 'archived' AND milestone IS NOT NULL
|
|
2687
|
+
GROUP BY milestone
|
|
2688
|
+
`
|
|
2689
|
+
)
|
|
2690
|
+
.all(project.id) as Array<{ milestone: string; count: number }>
|
|
2691
|
+
|
|
2692
|
+
// 총 태스크 수
|
|
2693
|
+
const total = sqlite
|
|
2694
|
+
.prepare(
|
|
2695
|
+
`
|
|
2696
|
+
SELECT COUNT(*) as count
|
|
2697
|
+
FROM tasks
|
|
2698
|
+
WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
|
|
2699
|
+
`
|
|
2700
|
+
)
|
|
2701
|
+
.get(project.id) as { count: number }
|
|
2702
|
+
|
|
2703
|
+
res.json({
|
|
2704
|
+
success: true,
|
|
2705
|
+
data: {
|
|
2706
|
+
total: total.count,
|
|
2707
|
+
byStatus: Object.fromEntries(byStatus.map((r) => [r.status, r.count])),
|
|
2708
|
+
byPriority: Object.fromEntries(byPriority.map((r) => [r.priority, r.count])),
|
|
2709
|
+
byMilestone: Object.fromEntries(byMilestone.map((r) => [r.milestone, r.count])),
|
|
2710
|
+
},
|
|
2711
|
+
})
|
|
2712
|
+
} catch (error) {
|
|
2713
|
+
console.error('Error getting backlog stats:', error)
|
|
2714
|
+
res.status(500).json({ success: false, error: 'Failed to get backlog stats' })
|
|
2715
|
+
}
|
|
2716
|
+
})
|
|
2717
|
+
|
|
2718
|
+
// =============================================
|
|
2719
|
+
// Migration API 엔드포인트
|
|
2720
|
+
// =============================================
|
|
2721
|
+
|
|
2722
|
+
// GET /backlog/migration/preview - 마이그레이션 대상 Inbox 태스크 미리보기
|
|
2723
|
+
flowRouter.get('/backlog/migration/preview', async (_req, res) => {
|
|
2724
|
+
try {
|
|
2725
|
+
await initTaskDb()
|
|
2726
|
+
const project = await getActiveProject()
|
|
2727
|
+
|
|
2728
|
+
if (!project) {
|
|
2729
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const preview = previewMigration(project.id)
|
|
2733
|
+
|
|
2734
|
+
res.json({
|
|
2735
|
+
success: true,
|
|
2736
|
+
data: preview,
|
|
2737
|
+
})
|
|
2738
|
+
} catch (error) {
|
|
2739
|
+
console.error('Error previewing migration:', error)
|
|
2740
|
+
res.status(500).json({ success: false, error: 'Failed to preview migration' })
|
|
2741
|
+
}
|
|
2742
|
+
})
|
|
2743
|
+
|
|
2744
|
+
// POST /backlog/migration - Inbox 전체를 Backlog로 마이그레이션
|
|
2745
|
+
flowRouter.post('/backlog/migration', async (_req, res) => {
|
|
2746
|
+
try {
|
|
2747
|
+
await initTaskDb()
|
|
2748
|
+
const project = await getActiveProject()
|
|
2749
|
+
|
|
2750
|
+
if (!project) {
|
|
2751
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
const result = await migrateInboxToBacklog(project.id, project.path)
|
|
2755
|
+
|
|
2756
|
+
// 마이그레이션 성공 시 WebSocket 이벤트 발생
|
|
2757
|
+
if (result.migratedCount > 0) {
|
|
2758
|
+
emit('backlog:synced', {
|
|
2759
|
+
projectId: project.id,
|
|
2760
|
+
synced: result.migratedCount,
|
|
2761
|
+
created: result.migratedCount,
|
|
2762
|
+
updated: 0,
|
|
2763
|
+
deleted: 0,
|
|
2764
|
+
})
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
res.json({
|
|
2768
|
+
success: result.success,
|
|
2769
|
+
data: result,
|
|
2770
|
+
})
|
|
2771
|
+
} catch (error) {
|
|
2772
|
+
console.error('Error migrating inbox to backlog:', error)
|
|
2773
|
+
res.status(500).json({ success: false, error: 'Failed to migrate inbox to backlog' })
|
|
2774
|
+
}
|
|
2775
|
+
})
|
|
2776
|
+
|
|
2777
|
+
// POST /backlog/migration/selected - 선택된 Inbox 태스크만 Backlog로 마이그레이션
|
|
2778
|
+
flowRouter.post('/backlog/migration/selected', async (req, res) => {
|
|
2779
|
+
try {
|
|
2780
|
+
await initTaskDb()
|
|
2781
|
+
const project = await getActiveProject()
|
|
2782
|
+
|
|
2783
|
+
if (!project) {
|
|
2784
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const { taskIds } = req.body
|
|
2788
|
+
|
|
2789
|
+
if (!Array.isArray(taskIds) || taskIds.length === 0) {
|
|
2790
|
+
return res.status(400).json({ success: false, error: 'taskIds array is required' })
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
const result = await migrateSelectedInboxTasks(project.id, project.path, taskIds)
|
|
2794
|
+
|
|
2795
|
+
// 마이그레이션 성공 시 WebSocket 이벤트 발생
|
|
2796
|
+
if (result.migratedCount > 0) {
|
|
2797
|
+
emit('backlog:synced', {
|
|
2798
|
+
projectId: project.id,
|
|
2799
|
+
synced: result.migratedCount,
|
|
2800
|
+
created: result.migratedCount,
|
|
2801
|
+
updated: 0,
|
|
2802
|
+
deleted: 0,
|
|
2803
|
+
})
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
res.json({
|
|
2807
|
+
success: result.success,
|
|
2808
|
+
data: result,
|
|
2809
|
+
})
|
|
2810
|
+
} catch (error) {
|
|
2811
|
+
console.error('Error migrating selected tasks:', error)
|
|
2812
|
+
res.status(500).json({ success: false, error: 'Failed to migrate selected tasks' })
|
|
2813
|
+
}
|
|
2814
|
+
})
|