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
package/server/app.ts
ADDED
|
@@ -0,0 +1,3693 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import cors from 'cors'
|
|
3
|
+
import { readdir, readFile, writeFile, access, mkdir } from 'fs/promises'
|
|
4
|
+
import type { Dirent } from 'fs'
|
|
5
|
+
import { join, basename } from 'path'
|
|
6
|
+
import os from 'os'
|
|
7
|
+
import { exec, spawn } from 'child_process'
|
|
8
|
+
import { promisify } from 'util'
|
|
9
|
+
import { parseTasksFile, toggleTaskInFile } from './parser.js'
|
|
10
|
+
import {
|
|
11
|
+
loadConfig,
|
|
12
|
+
addProject,
|
|
13
|
+
removeProject,
|
|
14
|
+
setActiveProject,
|
|
15
|
+
getActiveProject,
|
|
16
|
+
updateProjectPath,
|
|
17
|
+
updateProjectName,
|
|
18
|
+
reorderProjects,
|
|
19
|
+
} from './config.js'
|
|
20
|
+
import {
|
|
21
|
+
initDb,
|
|
22
|
+
createTask,
|
|
23
|
+
getTask,
|
|
24
|
+
listTasks,
|
|
25
|
+
updateTask,
|
|
26
|
+
deleteTask,
|
|
27
|
+
searchTasks,
|
|
28
|
+
getTasksByStatus,
|
|
29
|
+
archiveTask,
|
|
30
|
+
unarchiveTask,
|
|
31
|
+
TaskStatus,
|
|
32
|
+
TaskPriority,
|
|
33
|
+
} from './tasks/index.js'
|
|
34
|
+
import { gitRouter, gitPull } from './git/index.js'
|
|
35
|
+
import { emit } from './websocket.js'
|
|
36
|
+
import { integrationsRouter, initIntegrationsDb } from './integrations/index.js'
|
|
37
|
+
import { getSqlite } from './tasks/db/client.js'
|
|
38
|
+
import type { Stage, ChangeStatus } from './tasks/db/schema.js'
|
|
39
|
+
import { cliRoutes } from './cli-adapter/index.js'
|
|
40
|
+
import { docsRouter } from './routes/docs.js'
|
|
41
|
+
import { globalChatRouter } from './routes/global-chat.js'
|
|
42
|
+
import { projectsRouter } from './routes/projects.js'
|
|
43
|
+
import { startTasksWatcher, stopTasksWatcher } from './watcher.js'
|
|
44
|
+
import { changesRouter } from './routes/changes.js'
|
|
45
|
+
import { flowRouter } from './routes/flow.js'
|
|
46
|
+
import { alertsRouter, setBroadcastAlert } from './routes/alerts.js'
|
|
47
|
+
import { webhooksRouter, setWebhookBroadcast } from './routes/webhooks.js'
|
|
48
|
+
import { aiRouter } from './ai/index.js'
|
|
49
|
+
import { ragRouter, memoryRouter } from './routes/search.js'
|
|
50
|
+
import { leannRouter } from './routes/leann.js'
|
|
51
|
+
import { scanMoaiSpecs, scanRemoteMoaiSpecs } from './moai-specs.js'
|
|
52
|
+
import { specsRouter } from './routes/specs.js'
|
|
53
|
+
// Remote plugin (optional - only if installed)
|
|
54
|
+
let remoteRouter: import('express').Router | null = null
|
|
55
|
+
let getRemoteServerById: ((id: string) => Promise<import('@zyflow/remote-plugin').RemoteServer | null>) | null = null
|
|
56
|
+
let listDirectory: ((server: import('@zyflow/remote-plugin').RemoteServer, path: string) => Promise<import('@zyflow/remote-plugin').RemoteDirectoryListing>) | null = null
|
|
57
|
+
let readRemoteFile: ((server: import('@zyflow/remote-plugin').RemoteServer, path: string) => Promise<string>) | null = null
|
|
58
|
+
let executeCommand: ((server: import('@zyflow/remote-plugin').RemoteServer, command: string, options?: { cwd?: string; timeout?: number }) => Promise<import('@zyflow/remote-plugin').RemoteCommandResult>) | null = null
|
|
59
|
+
|
|
60
|
+
// Try to load remote plugin
|
|
61
|
+
try {
|
|
62
|
+
const remotePlugin = await import('@zyflow/remote-plugin')
|
|
63
|
+
remoteRouter = remotePlugin.remoteRouter
|
|
64
|
+
getRemoteServerById = remotePlugin.getRemoteServerById
|
|
65
|
+
listDirectory = remotePlugin.listDirectory
|
|
66
|
+
readRemoteFile = remotePlugin.readRemoteFile
|
|
67
|
+
executeCommand = remotePlugin.executeCommand
|
|
68
|
+
console.log('[Remote Plugin] Loaded successfully')
|
|
69
|
+
} catch {
|
|
70
|
+
console.log('[Remote Plugin] Not installed - remote features disabled')
|
|
71
|
+
}
|
|
72
|
+
import { OpenSpecPromptBuilder } from './claude-flow/prompt-builder.js'
|
|
73
|
+
import * as pty from 'node-pty'
|
|
74
|
+
const execAsync = promisify(exec)
|
|
75
|
+
|
|
76
|
+
// Lazy load gitdiagram-core functions to avoid ESM/CJS issues
|
|
77
|
+
let gitdiagramCore: {
|
|
78
|
+
generateFileTree: typeof import('../packages/gitdiagram-core/src/file-tree').generateFileTree
|
|
79
|
+
readReadme: typeof import('../packages/gitdiagram-core/src/file-tree').readReadme
|
|
80
|
+
generateDiagram: typeof import('../packages/gitdiagram-core/src/generator').generateDiagram
|
|
81
|
+
createLLMAdapter: typeof import('../packages/gitdiagram-core/src/llm-adapter').createLLMAdapter
|
|
82
|
+
validateMermaidSyntax: typeof import('../packages/gitdiagram-core/src/mermaid-utils').validateMermaidSyntax
|
|
83
|
+
extractClickEvents: typeof import('../packages/gitdiagram-core/src/mermaid-utils').extractClickEvents
|
|
84
|
+
} | null = null
|
|
85
|
+
|
|
86
|
+
async function getGitdiagramCore() {
|
|
87
|
+
if (!gitdiagramCore) {
|
|
88
|
+
const [fileTree, generator, llmAdapter, mermaidUtils] = await Promise.all([
|
|
89
|
+
import('../packages/gitdiagram-core/src/file-tree.js'),
|
|
90
|
+
import('../packages/gitdiagram-core/src/generator.js'),
|
|
91
|
+
import('../packages/gitdiagram-core/src/llm-adapter.js'),
|
|
92
|
+
import('../packages/gitdiagram-core/src/mermaid-utils.js'),
|
|
93
|
+
])
|
|
94
|
+
gitdiagramCore = {
|
|
95
|
+
generateFileTree: fileTree.generateFileTree,
|
|
96
|
+
readReadme: fileTree.readReadme,
|
|
97
|
+
generateDiagram: generator.generateDiagram,
|
|
98
|
+
createLLMAdapter: llmAdapter.createLLMAdapter,
|
|
99
|
+
validateMermaidSyntax: mermaidUtils.validateMermaidSyntax,
|
|
100
|
+
extractClickEvents: mermaidUtils.extractClickEvents,
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return gitdiagramCore
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Store running Claude processes
|
|
107
|
+
const runningTasks = new Map<
|
|
108
|
+
string,
|
|
109
|
+
{
|
|
110
|
+
process: ReturnType<typeof spawn>
|
|
111
|
+
output: string[]
|
|
112
|
+
status: 'running' | 'completed' | 'error'
|
|
113
|
+
startedAt: Date
|
|
114
|
+
}
|
|
115
|
+
>()
|
|
116
|
+
|
|
117
|
+
export const app = express()
|
|
118
|
+
|
|
119
|
+
// CORS 정책: 허용된 origin만 접근 가능 (보안 강화)
|
|
120
|
+
const allowedOrigins = [
|
|
121
|
+
'http://localhost:3100', // zyflow-web
|
|
122
|
+
'http://localhost:3200', // _flow-web
|
|
123
|
+
'http://127.0.0.1:3100',
|
|
124
|
+
'http://127.0.0.1:3200',
|
|
125
|
+
...(process.env.ALLOWED_ORIGINS?.split(',') || []),
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
app.use(cors({
|
|
129
|
+
origin: (origin, callback) => {
|
|
130
|
+
// Allow requests with no origin (like mobile apps or curl)
|
|
131
|
+
if (!origin) return callback(null, true)
|
|
132
|
+
if (allowedOrigins.includes(origin)) {
|
|
133
|
+
return callback(null, true)
|
|
134
|
+
}
|
|
135
|
+
callback(new Error('CORS policy violation'))
|
|
136
|
+
},
|
|
137
|
+
credentials: true,
|
|
138
|
+
}))
|
|
139
|
+
app.use(express.json())
|
|
140
|
+
|
|
141
|
+
// Git API 라우터 등록
|
|
142
|
+
app.use('/api/git', gitRouter)
|
|
143
|
+
|
|
144
|
+
// Integration Hub API 라우터 등록
|
|
145
|
+
initIntegrationsDb()
|
|
146
|
+
app.use('/api/integrations', integrationsRouter)
|
|
147
|
+
|
|
148
|
+
// CLI Adapter API 라우터 등록
|
|
149
|
+
app.use('/api/cli', cliRoutes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
// AI Execution API 라우터 등록 (단일 Provider 실행)
|
|
153
|
+
app.use('/api/ai', aiRouter)
|
|
154
|
+
|
|
155
|
+
// Docs API 라우터 등록
|
|
156
|
+
app.use('/api/docs', docsRouter)
|
|
157
|
+
|
|
158
|
+
// RAG 검색 API 라우터 등록 (LEANN 연동)
|
|
159
|
+
app.use('/api/rag', ragRouter)
|
|
160
|
+
|
|
161
|
+
// Memory 검색 API 라우터 등록 (claude-mem 연동)
|
|
162
|
+
app.use('/api/memory', memoryRouter)
|
|
163
|
+
|
|
164
|
+
// LEANN 인덱스 관리 API 라우터 등록
|
|
165
|
+
app.use('/api/leann', leannRouter)
|
|
166
|
+
|
|
167
|
+
// Unified SPEC Scanner API 라우터 등록 (SPEC-VISIBILITY-001)
|
|
168
|
+
app.use('/api/specs', specsRouter)
|
|
169
|
+
|
|
170
|
+
// Global Chat API 라우터 등록
|
|
171
|
+
app.use('/api/chat', globalChatRouter)
|
|
172
|
+
|
|
173
|
+
// Projects API 라우터 등록
|
|
174
|
+
app.use('/api/projects', projectsRouter)
|
|
175
|
+
|
|
176
|
+
// Changes API 라우터 등록
|
|
177
|
+
app.use('/api/changes', changesRouter)
|
|
178
|
+
|
|
179
|
+
// Flow API 라우터 등록
|
|
180
|
+
app.use('/api/flow', flowRouter)
|
|
181
|
+
|
|
182
|
+
// Alerts API 라우터 등록
|
|
183
|
+
app.use('/api/alerts', alertsRouter)
|
|
184
|
+
|
|
185
|
+
// Webhooks API 라우터 등록
|
|
186
|
+
app.use('/api/alerts/webhooks', webhooksRouter)
|
|
187
|
+
|
|
188
|
+
// Remote Server API 라우터 등록 (플러그인이 설치된 경우만)
|
|
189
|
+
if (remoteRouter) {
|
|
190
|
+
app.use('/api/remote', remoteRouter)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Alert WebSocket broadcast 설정
|
|
194
|
+
setBroadcastAlert((data) => {
|
|
195
|
+
emit('alert', data)
|
|
196
|
+
})
|
|
197
|
+
setWebhookBroadcast((data) => {
|
|
198
|
+
emit('alert', data)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Health check endpoint
|
|
202
|
+
app.get('/api/health', (_req, res) => {
|
|
203
|
+
res.json({
|
|
204
|
+
success: true,
|
|
205
|
+
data: {
|
|
206
|
+
status: 'ok',
|
|
207
|
+
timestamp: new Date().toISOString(),
|
|
208
|
+
uptime: process.uptime(),
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Instance info endpoint (for multi-instance branding)
|
|
214
|
+
app.get('/api/instance', (_req, res) => {
|
|
215
|
+
const instanceName = process.env.INSTANCE_NAME || 'zyflow'
|
|
216
|
+
const displayName = process.env.INSTANCE_DISPLAY_NAME || 'ZyFlow'
|
|
217
|
+
const defaultProjectRoot = process.env.DEFAULT_PROJECT_ROOT || os.homedir()
|
|
218
|
+
|
|
219
|
+
res.json({
|
|
220
|
+
success: true,
|
|
221
|
+
data: {
|
|
222
|
+
name: instanceName,
|
|
223
|
+
displayName: displayName,
|
|
224
|
+
port: process.env.PORT || 3100,
|
|
225
|
+
dataDir: process.env.DATA_DIR || join(os.homedir(), '.zyflow'),
|
|
226
|
+
defaultProjectRoot: defaultProjectRoot.replace(/^~/, os.homedir()),
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// Helper to get paths for active project
|
|
232
|
+
async function getProjectPaths() {
|
|
233
|
+
const project = await getActiveProject()
|
|
234
|
+
if (!project) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
projectPath: project.path,
|
|
239
|
+
openspecDir: join(project.path, 'openspec', 'changes'),
|
|
240
|
+
specsDir: join(project.path, 'openspec', 'specs'),
|
|
241
|
+
plansDir: join(project.path, '.zyflow', 'plans'),
|
|
242
|
+
// Archive directories (three possible locations)
|
|
243
|
+
archiveDir: join(project.path, 'openspec', 'changes', 'archive'), // openspec/changes/archive/
|
|
244
|
+
legacyArchiveDir: join(project.path, 'openspec', 'archive'), // openspec/archive/
|
|
245
|
+
archivedDir: join(project.path, 'openspec', 'archived'), // openspec/archived/
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ==================== PROJECT MANAGEMENT ====================
|
|
250
|
+
|
|
251
|
+
// POST /api/projects/browse - Open native folder picker dialog
|
|
252
|
+
app.post('/api/projects/browse', async (_req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
// macOS: Use AppleScript to open folder picker (simplified version)
|
|
255
|
+
const script = `osascript -e 'POSIX path of (choose folder with prompt "OpenSpec 프로젝트 폴더를 선택하세요")'`
|
|
256
|
+
|
|
257
|
+
const { stdout } = await execAsync(script, { timeout: 120000 }) // 2분 타임아웃
|
|
258
|
+
const selectedPath = stdout.trim().replace(/\/$/, '') // Remove trailing slash
|
|
259
|
+
|
|
260
|
+
if (!selectedPath) {
|
|
261
|
+
return res.json({ success: true, data: { path: null, cancelled: true } })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
res.json({ success: true, data: { path: selectedPath, cancelled: false } })
|
|
265
|
+
} catch (error) {
|
|
266
|
+
const errorMessage = (error as Error).message || ''
|
|
267
|
+
// User cancelled the dialog (error code -128)
|
|
268
|
+
if (
|
|
269
|
+
errorMessage.includes('-128') ||
|
|
270
|
+
errorMessage.includes('User canceled') ||
|
|
271
|
+
errorMessage.includes('취소')
|
|
272
|
+
) {
|
|
273
|
+
return res.json({ success: true, data: { path: null, cancelled: true } })
|
|
274
|
+
}
|
|
275
|
+
console.error('Error opening folder picker:', error)
|
|
276
|
+
res.status(500).json({ success: false, error: 'Failed to open folder picker' })
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// GET /api/projects - List all registered projects
|
|
281
|
+
app.get('/api/projects', async (_req, res) => {
|
|
282
|
+
try {
|
|
283
|
+
const config = await loadConfig()
|
|
284
|
+
res.json({
|
|
285
|
+
success: true,
|
|
286
|
+
data: {
|
|
287
|
+
projects: config.projects,
|
|
288
|
+
activeProjectId: config.activeProjectId,
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('Error listing projects:', error)
|
|
293
|
+
res.status(500).json({ success: false, error: 'Failed to list projects' })
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// POST /api/projects - Add a new project
|
|
298
|
+
app.post('/api/projects', async (req, res) => {
|
|
299
|
+
try {
|
|
300
|
+
const { path: projectPath } = req.body
|
|
301
|
+
|
|
302
|
+
if (!projectPath) {
|
|
303
|
+
return res.status(400).json({ success: false, error: 'Path is required' })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check if openspec directory exists
|
|
307
|
+
const openspecPath = join(projectPath, 'openspec')
|
|
308
|
+
try {
|
|
309
|
+
await access(openspecPath)
|
|
310
|
+
} catch {
|
|
311
|
+
return res.status(400).json({
|
|
312
|
+
success: false,
|
|
313
|
+
error: 'No openspec directory found in this project',
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Use directory name as project name
|
|
318
|
+
const name = basename(projectPath)
|
|
319
|
+
const project = await addProject(name, projectPath)
|
|
320
|
+
|
|
321
|
+
res.json({ success: true, data: { project } })
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error('Error adding project:', error)
|
|
324
|
+
res.status(500).json({ success: false, error: 'Failed to add project' })
|
|
325
|
+
}
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// PUT /api/projects/reorder - Reorder projects
|
|
329
|
+
// NOTE: This must be defined BEFORE /api/projects/:id routes to avoid :id matching "reorder"
|
|
330
|
+
app.put('/api/projects/reorder', async (req, res) => {
|
|
331
|
+
try {
|
|
332
|
+
const { projectIds } = req.body
|
|
333
|
+
|
|
334
|
+
if (!projectIds || !Array.isArray(projectIds)) {
|
|
335
|
+
return res.status(400).json({ success: false, error: 'projectIds array is required' })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const projects = await reorderProjects(projectIds)
|
|
339
|
+
res.json({ success: true, data: { projects } })
|
|
340
|
+
} catch (error) {
|
|
341
|
+
console.error('Error reordering projects:', error)
|
|
342
|
+
res.status(500).json({
|
|
343
|
+
success: false,
|
|
344
|
+
error: error instanceof Error ? error.message : 'Failed to reorder projects',
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// DELETE /api/projects/:id - Remove a project
|
|
350
|
+
app.delete('/api/projects/:id', async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const projectId = req.params.id
|
|
353
|
+
await removeProject(projectId)
|
|
354
|
+
res.json({ success: true })
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.error('Error removing project:', error)
|
|
357
|
+
res.status(500).json({ success: false, error: 'Failed to remove project' })
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
// NOTE: /api/projects/:id/activate 핸들러는 routes/projects.ts에서 최적화된 버전으로 제공됩니다.
|
|
362
|
+
|
|
363
|
+
// PUT /api/projects/:id/path - Update project path
|
|
364
|
+
app.put('/api/projects/:id/path', async (req, res) => {
|
|
365
|
+
try {
|
|
366
|
+
const projectId = req.params.id
|
|
367
|
+
const { path: newPath } = req.body
|
|
368
|
+
|
|
369
|
+
if (!newPath) {
|
|
370
|
+
return res.status(400).json({ success: false, error: 'Path is required' })
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Check if openspec directory exists in new path
|
|
374
|
+
const openspecPath = join(newPath, 'openspec')
|
|
375
|
+
try {
|
|
376
|
+
await access(openspecPath)
|
|
377
|
+
} catch {
|
|
378
|
+
return res.status(400).json({
|
|
379
|
+
success: false,
|
|
380
|
+
error: 'No openspec directory found in the specified path',
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const project = await updateProjectPath(projectId, newPath)
|
|
385
|
+
|
|
386
|
+
res.json({ success: true, data: { project } })
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error('Error updating project path:', error)
|
|
389
|
+
res.status(500).json({
|
|
390
|
+
success: false,
|
|
391
|
+
error: error instanceof Error ? error.message : 'Failed to update project path',
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
// GET /api/projects/:id/changes - Get changes for a project
|
|
397
|
+
app.get('/api/projects/:id/changes', async (req, res) => {
|
|
398
|
+
try {
|
|
399
|
+
const projectId = req.params.id
|
|
400
|
+
const { getSqlite } = await import('./tasks/db/client.js')
|
|
401
|
+
const sqlite = getSqlite()
|
|
402
|
+
|
|
403
|
+
const changes = sqlite
|
|
404
|
+
.prepare(
|
|
405
|
+
`
|
|
406
|
+
SELECT id, title, status, current_stage, progress, spec_path, created_at, updated_at
|
|
407
|
+
FROM changes
|
|
408
|
+
WHERE project_id = ?
|
|
409
|
+
ORDER BY updated_at DESC
|
|
410
|
+
`
|
|
411
|
+
)
|
|
412
|
+
.all(projectId)
|
|
413
|
+
|
|
414
|
+
res.json({ success: true, changes })
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error('Error fetching project changes:', error)
|
|
417
|
+
res.status(500).json({
|
|
418
|
+
success: false,
|
|
419
|
+
error: error instanceof Error ? error.message : 'Failed to fetch changes',
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// PUT /api/projects/:id/name - Update project name
|
|
425
|
+
app.put('/api/projects/:id/name', async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
const projectId = req.params.id
|
|
428
|
+
const { name: newName } = req.body
|
|
429
|
+
|
|
430
|
+
if (!newName || typeof newName !== 'string') {
|
|
431
|
+
return res.status(400).json({ success: false, error: 'Name is required' })
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const trimmedName = newName.trim()
|
|
435
|
+
if (trimmedName.length === 0) {
|
|
436
|
+
return res.status(400).json({ success: false, error: 'Name cannot be empty' })
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const project = await updateProjectName(projectId, trimmedName)
|
|
440
|
+
res.json({ success: true, data: { project } })
|
|
441
|
+
} catch (error) {
|
|
442
|
+
console.error('Error updating project name:', error)
|
|
443
|
+
res.status(500).json({
|
|
444
|
+
success: false,
|
|
445
|
+
error: error instanceof Error ? error.message : 'Failed to update project name',
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// ==================== ALL PROJECTS DATA ====================
|
|
451
|
+
|
|
452
|
+
// Helper to parse affected specs from proposal content
|
|
453
|
+
function parseAffectedSpecs(proposalContent: string): string[] {
|
|
454
|
+
const specs: string[] = []
|
|
455
|
+
|
|
456
|
+
// Find ### Affected Specs section
|
|
457
|
+
const affectedSpecsMatch = proposalContent.match(
|
|
458
|
+
/###\s*Affected Specs\s*\n([\s\S]*?)(?=\n###|\n##|$)/i
|
|
459
|
+
)
|
|
460
|
+
if (!affectedSpecsMatch) return specs
|
|
461
|
+
|
|
462
|
+
const section = affectedSpecsMatch[1]
|
|
463
|
+
// Match patterns like: - **NEW**: `spec-name` or - **MODIFIED**: `spec-name`
|
|
464
|
+
const specMatches = section.matchAll(/`([^`]+)`/g)
|
|
465
|
+
for (const match of specMatches) {
|
|
466
|
+
specs.push(match[1])
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return specs
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Helper to get changes for a specific project (supports both local and remote)
|
|
473
|
+
async function getChangesForProject(project: {
|
|
474
|
+
id: string
|
|
475
|
+
path: string
|
|
476
|
+
remote?: { serverId: string }
|
|
477
|
+
}) {
|
|
478
|
+
const projectPath = project.path
|
|
479
|
+
|
|
480
|
+
// Get archived change IDs from DB to filter them out
|
|
481
|
+
const archivedChangeIds = new Set<string>()
|
|
482
|
+
try {
|
|
483
|
+
const sqlite = getSqlite()
|
|
484
|
+
const archivedRows = sqlite
|
|
485
|
+
.prepare(
|
|
486
|
+
`
|
|
487
|
+
SELECT id FROM changes WHERE project_id = ? AND status = 'archived'
|
|
488
|
+
`
|
|
489
|
+
)
|
|
490
|
+
.all(project.id) as { id: string }[]
|
|
491
|
+
for (const row of archivedRows) {
|
|
492
|
+
archivedChangeIds.add(row.id)
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// DB not initialized yet, proceed without filtering
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 원격 프로젝트인 경우 (플러그인이 설치된 경우만)
|
|
499
|
+
if (project.remote) {
|
|
500
|
+
if (!getRemoteServerById || !listDirectory || !readRemoteFile || !executeCommand) {
|
|
501
|
+
console.log('[Remote] Plugin not installed - cannot access remote project')
|
|
502
|
+
return []
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const server = await getRemoteServerById(project.remote.serverId)
|
|
506
|
+
if (!server) {
|
|
507
|
+
return []
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const openspecDir = `${projectPath}/openspec/changes`
|
|
511
|
+
|
|
512
|
+
// 원격 디렉토리 목록 조회
|
|
513
|
+
let listing
|
|
514
|
+
try {
|
|
515
|
+
listing = await listDirectory(server, openspecDir)
|
|
516
|
+
} catch {
|
|
517
|
+
return []
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 디렉토리만 필터링 (archive 제외)
|
|
521
|
+
const validEntries = listing.entries.filter(
|
|
522
|
+
(entry) => entry.type === 'directory' && entry.name !== 'archive'
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
// 병렬로 각 change 처리
|
|
526
|
+
const changes = await Promise.all(
|
|
527
|
+
validEntries.map(async (entry) => {
|
|
528
|
+
const changeId = entry.name
|
|
529
|
+
|
|
530
|
+
// Skip archived changes (based on DB status)
|
|
531
|
+
if (archivedChangeIds.has(changeId)) return null
|
|
532
|
+
|
|
533
|
+
const changeDir = `${openspecDir}/${changeId}`
|
|
534
|
+
|
|
535
|
+
// 원격에서 proposal.md, tasks.md 읽기 및 git log 실행
|
|
536
|
+
const [proposalResult, tasksResult, gitResult] = await Promise.allSettled([
|
|
537
|
+
readRemoteFile(server, `${changeDir}/proposal.md`),
|
|
538
|
+
readRemoteFile(server, `${changeDir}/tasks.md`),
|
|
539
|
+
executeCommand(server, `git log -1 --format="%aI" -- "openspec/changes/${changeId}"`, {
|
|
540
|
+
cwd: projectPath,
|
|
541
|
+
}),
|
|
542
|
+
])
|
|
543
|
+
|
|
544
|
+
// Parse proposal
|
|
545
|
+
let title = changeId
|
|
546
|
+
let relatedSpecs: string[] = []
|
|
547
|
+
if (proposalResult.status === 'fulfilled') {
|
|
548
|
+
const titleMatch = proposalResult.value.match(/^#\s+(?:Change:\s+)?(.+)$/m)
|
|
549
|
+
if (titleMatch) title = titleMatch[1].trim()
|
|
550
|
+
relatedSpecs = parseAffectedSpecs(proposalResult.value)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Parse tasks
|
|
554
|
+
let totalTasks = 0
|
|
555
|
+
let completedTasks = 0
|
|
556
|
+
if (tasksResult.status === 'fulfilled') {
|
|
557
|
+
const parsed = parseTasksFile(changeId, tasksResult.value)
|
|
558
|
+
for (const group of parsed.groups) {
|
|
559
|
+
totalTasks += group.tasks.length
|
|
560
|
+
completedTasks += group.tasks.filter((t) => t.completed).length
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
565
|
+
const status = progress === 100 ? 'completed' : 'active'
|
|
566
|
+
|
|
567
|
+
// Get updatedAt from git log or file modifiedAt
|
|
568
|
+
let updatedAt: string | null = null
|
|
569
|
+
if (gitResult.status === 'fulfilled') {
|
|
570
|
+
const stdout = gitResult.value.stdout.trim()
|
|
571
|
+
if (stdout) {
|
|
572
|
+
updatedAt = stdout
|
|
573
|
+
} else {
|
|
574
|
+
// git log 결과가 비어있으면 modifiedAt 사용
|
|
575
|
+
updatedAt = entry.modifiedAt || new Date().toISOString()
|
|
576
|
+
}
|
|
577
|
+
} else {
|
|
578
|
+
// git log 실패 시 modifiedAt 사용
|
|
579
|
+
updatedAt = entry.modifiedAt || new Date().toISOString()
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
id: changeId,
|
|
584
|
+
title,
|
|
585
|
+
progress,
|
|
586
|
+
totalTasks,
|
|
587
|
+
completedTasks,
|
|
588
|
+
relatedSpecs,
|
|
589
|
+
updatedAt,
|
|
590
|
+
type: 'openspec' as const,
|
|
591
|
+
status,
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
const openspecChanges = changes.filter((c): c is NonNullable<typeof c> => c !== null)
|
|
597
|
+
|
|
598
|
+
// 2. 원격 MoAI SPECs 스캔 (.moai/specs/)
|
|
599
|
+
const moaiSpecChanges: typeof openspecChanges = []
|
|
600
|
+
try {
|
|
601
|
+
const plugin = {
|
|
602
|
+
listDirectory: listDirectory!,
|
|
603
|
+
readRemoteFile: readRemoteFile!,
|
|
604
|
+
}
|
|
605
|
+
const moaiSpecs = await scanRemoteMoaiSpecs(projectPath, server, plugin)
|
|
606
|
+
for (const spec of moaiSpecs) {
|
|
607
|
+
// Skip archived SPECs
|
|
608
|
+
if (archivedChangeIds.has(spec.id) || spec.status === 'archived') continue
|
|
609
|
+
|
|
610
|
+
const progress = spec.tagCount > 0
|
|
611
|
+
? Math.round((spec.completedTags / spec.tagCount) * 100)
|
|
612
|
+
: 0
|
|
613
|
+
|
|
614
|
+
moaiSpecChanges.push({
|
|
615
|
+
id: spec.id,
|
|
616
|
+
title: spec.title,
|
|
617
|
+
progress,
|
|
618
|
+
totalTasks: spec.tagCount,
|
|
619
|
+
completedTasks: spec.completedTags,
|
|
620
|
+
updatedAt: new Date().toISOString(),
|
|
621
|
+
type: 'spec' as const,
|
|
622
|
+
status: spec.status === 'complete' ? 'completed' : spec.status,
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
} catch (err) {
|
|
626
|
+
console.warn('[Remote] Failed to scan remote MoAI SPECs:', err)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return [...moaiSpecChanges, ...openspecChanges]
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// 로컬 프로젝트인 경우
|
|
633
|
+
const changes: Array<{
|
|
634
|
+
id: string
|
|
635
|
+
title: string
|
|
636
|
+
progress: number
|
|
637
|
+
totalTasks: number
|
|
638
|
+
completedTasks: number
|
|
639
|
+
relatedSpecs?: string[]
|
|
640
|
+
updatedAt: string | null
|
|
641
|
+
type?: 'openspec' | 'spec'
|
|
642
|
+
}> = []
|
|
643
|
+
|
|
644
|
+
// 1. MoAI SPECs 스캔 (.moai/specs/)
|
|
645
|
+
try {
|
|
646
|
+
const moaiSpecs = await scanMoaiSpecs(projectPath)
|
|
647
|
+
for (const spec of moaiSpecs) {
|
|
648
|
+
// Skip archived SPECs
|
|
649
|
+
if (archivedChangeIds.has(spec.id) || spec.status === 'archived') continue
|
|
650
|
+
|
|
651
|
+
const progress = spec.tagCount > 0
|
|
652
|
+
? Math.round((spec.completedTags / spec.tagCount) * 100)
|
|
653
|
+
: 0
|
|
654
|
+
|
|
655
|
+
// Get updatedAt from git or file system
|
|
656
|
+
let updatedAt: string | null = null
|
|
657
|
+
try {
|
|
658
|
+
const gitResult = await execAsync(
|
|
659
|
+
`git log -1 --format="%aI" -- ".moai/specs/${spec.id}"`,
|
|
660
|
+
{ cwd: projectPath }
|
|
661
|
+
)
|
|
662
|
+
updatedAt = gitResult.stdout.trim() || new Date().toISOString()
|
|
663
|
+
} catch {
|
|
664
|
+
updatedAt = new Date().toISOString()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
changes.push({
|
|
668
|
+
id: spec.id,
|
|
669
|
+
title: spec.title,
|
|
670
|
+
progress,
|
|
671
|
+
totalTasks: spec.tagCount,
|
|
672
|
+
completedTasks: spec.completedTags,
|
|
673
|
+
updatedAt,
|
|
674
|
+
type: 'spec',
|
|
675
|
+
status: spec.status === 'complete' ? 'completed' : spec.status,
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
// .moai/specs/ scan failed, continue with OpenSpec
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 2. OpenSpec changes 스캔 (openspec/changes/)
|
|
683
|
+
const openspecDir = join(projectPath, 'openspec', 'changes')
|
|
684
|
+
|
|
685
|
+
let entries: Dirent[] = []
|
|
686
|
+
try {
|
|
687
|
+
entries = await readdir(openspecDir, { withFileTypes: true })
|
|
688
|
+
} catch {
|
|
689
|
+
// openspec/changes/ does not exist, return MoAI SPECs only
|
|
690
|
+
return changes
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
for (const entry of entries) {
|
|
694
|
+
if (!entry.isDirectory() || entry.name === 'archive') continue
|
|
695
|
+
|
|
696
|
+
const changeId = entry.name
|
|
697
|
+
|
|
698
|
+
// Skip archived changes (based on DB status)
|
|
699
|
+
if (archivedChangeIds.has(changeId)) continue
|
|
700
|
+
const changeDir = join(openspecDir, changeId)
|
|
701
|
+
|
|
702
|
+
let title = changeId
|
|
703
|
+
let relatedSpecs: string[] = []
|
|
704
|
+
try {
|
|
705
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
706
|
+
const proposalContent = await readFile(proposalPath, 'utf-8')
|
|
707
|
+
// Try to match "# Change: ..." or just first "# ..." heading
|
|
708
|
+
const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
|
|
709
|
+
if (titleMatch) {
|
|
710
|
+
title = titleMatch[1].trim()
|
|
711
|
+
}
|
|
712
|
+
// Parse affected specs
|
|
713
|
+
relatedSpecs = parseAffectedSpecs(proposalContent)
|
|
714
|
+
} catch {
|
|
715
|
+
// proposal.md not found
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let totalTasks = 0
|
|
719
|
+
let completedTasks = 0
|
|
720
|
+
try {
|
|
721
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
722
|
+
const tasksContent = await readFile(tasksPath, 'utf-8')
|
|
723
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
724
|
+
for (const group of parsed.groups) {
|
|
725
|
+
totalTasks += group.tasks.length
|
|
726
|
+
completedTasks += group.tasks.filter((t) => t.completed).length
|
|
727
|
+
}
|
|
728
|
+
} catch {
|
|
729
|
+
// tasks.md not found
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Get last modified date from git
|
|
733
|
+
let updatedAt: string | null = null
|
|
734
|
+
try {
|
|
735
|
+
const relativeChangeDir = `openspec/changes/${changeId}`
|
|
736
|
+
const gitCmd = `git log -1 --format="%aI" -- "${relativeChangeDir}"`
|
|
737
|
+
const { stdout } = await execAsync(gitCmd, { cwd: projectPath })
|
|
738
|
+
if (stdout.trim()) {
|
|
739
|
+
updatedAt = stdout.trim()
|
|
740
|
+
} else {
|
|
741
|
+
// Fallback to file stat
|
|
742
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
743
|
+
const stat = await import('fs/promises').then((fs) => fs.stat(tasksPath))
|
|
744
|
+
updatedAt = stat.mtime.toISOString()
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
updatedAt = new Date().toISOString()
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
751
|
+
const status = progress === 100 ? 'completed' : 'active'
|
|
752
|
+
changes.push({
|
|
753
|
+
id: changeId,
|
|
754
|
+
title,
|
|
755
|
+
progress,
|
|
756
|
+
totalTasks,
|
|
757
|
+
completedTasks,
|
|
758
|
+
relatedSpecs,
|
|
759
|
+
updatedAt,
|
|
760
|
+
type: 'openspec',
|
|
761
|
+
status,
|
|
762
|
+
})
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return changes
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Helper to get specs for a specific project path
|
|
769
|
+
async function getSpecsForProject(projectPath: string) {
|
|
770
|
+
const specsDir = join(projectPath, 'openspec', 'specs')
|
|
771
|
+
const specs = []
|
|
772
|
+
|
|
773
|
+
let entries
|
|
774
|
+
try {
|
|
775
|
+
entries = await readdir(specsDir, { withFileTypes: true })
|
|
776
|
+
} catch {
|
|
777
|
+
return []
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
for (const entry of entries) {
|
|
781
|
+
if (!entry.isDirectory()) continue
|
|
782
|
+
|
|
783
|
+
const specId = entry.name
|
|
784
|
+
const specDir = join(specsDir, specId)
|
|
785
|
+
|
|
786
|
+
let title = specId
|
|
787
|
+
let requirementsCount = 0
|
|
788
|
+
try {
|
|
789
|
+
const specPath = join(specDir, 'spec.md')
|
|
790
|
+
const specContent = await readFile(specPath, 'utf-8')
|
|
791
|
+
const titleMatch = specContent.match(/^#\s+(.+)$/m)
|
|
792
|
+
if (titleMatch) {
|
|
793
|
+
title = titleMatch[1].trim()
|
|
794
|
+
}
|
|
795
|
+
const reqMatches = specContent.match(/^###\s+Requirement:/gm)
|
|
796
|
+
requirementsCount = reqMatches ? reqMatches.length : 0
|
|
797
|
+
} catch {
|
|
798
|
+
// spec.md not found
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
specs.push({ id: specId, title, requirementsCount })
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return specs
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// GET /api/projects/all-data - Get all projects with their changes and specs
|
|
808
|
+
app.get('/api/projects/all-data', async (_req, res) => {
|
|
809
|
+
try {
|
|
810
|
+
const config = await loadConfig()
|
|
811
|
+
const projectsData = []
|
|
812
|
+
|
|
813
|
+
for (const project of config.projects) {
|
|
814
|
+
const changes = await getChangesForProject(project)
|
|
815
|
+
const specs = await getSpecsForProject(project.path)
|
|
816
|
+
projectsData.push({
|
|
817
|
+
...project,
|
|
818
|
+
changes,
|
|
819
|
+
specs,
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
res.json({
|
|
824
|
+
success: true,
|
|
825
|
+
data: {
|
|
826
|
+
projects: projectsData,
|
|
827
|
+
activeProjectId: config.activeProjectId,
|
|
828
|
+
},
|
|
829
|
+
})
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.error('Error getting all projects data:', error)
|
|
832
|
+
res.status(500).json({ success: false, error: 'Failed to get projects data' })
|
|
833
|
+
}
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
// ==================== CHANGES ====================
|
|
837
|
+
|
|
838
|
+
// GET /api/changes - List all changes
|
|
839
|
+
app.get('/api/changes', async (_req, res) => {
|
|
840
|
+
try {
|
|
841
|
+
const paths = await getProjectPaths()
|
|
842
|
+
if (!paths) {
|
|
843
|
+
return res.json({ success: true, data: { changes: [] } })
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
let entries
|
|
847
|
+
try {
|
|
848
|
+
entries = await readdir(paths.openspecDir, { withFileTypes: true })
|
|
849
|
+
} catch {
|
|
850
|
+
return res.json({ success: true, data: { changes: [] } })
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const changes = []
|
|
854
|
+
|
|
855
|
+
for (const entry of entries) {
|
|
856
|
+
if (!entry.isDirectory() || entry.name === 'archive') continue
|
|
857
|
+
|
|
858
|
+
const changeId = entry.name
|
|
859
|
+
const changeDir = join(paths.openspecDir, changeId)
|
|
860
|
+
|
|
861
|
+
// Read proposal.md for title
|
|
862
|
+
let title = changeId
|
|
863
|
+
try {
|
|
864
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
865
|
+
const proposalContent = await readFile(proposalPath, 'utf-8')
|
|
866
|
+
const titleMatch = proposalContent.match(/^#\s+Change:\s+(.+)$/m)
|
|
867
|
+
if (titleMatch) {
|
|
868
|
+
title = titleMatch[1].trim()
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// No proposal.md, use directory name
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Read tasks.md for progress
|
|
875
|
+
let totalTasks = 0
|
|
876
|
+
let completedTasks = 0
|
|
877
|
+
try {
|
|
878
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
879
|
+
const tasksContent = await readFile(tasksPath, 'utf-8')
|
|
880
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
881
|
+
|
|
882
|
+
for (const group of parsed.groups) {
|
|
883
|
+
totalTasks += group.tasks.length
|
|
884
|
+
completedTasks += group.tasks.filter((t) => t.completed).length
|
|
885
|
+
}
|
|
886
|
+
} catch {
|
|
887
|
+
// No tasks.md
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
891
|
+
|
|
892
|
+
// Get last modified date from git (latest commit in change directory)
|
|
893
|
+
let updatedAt: string | null = null
|
|
894
|
+
try {
|
|
895
|
+
// Get latest commit date for any file in the change directory
|
|
896
|
+
// Use relative path from project root for git command
|
|
897
|
+
const relativeChangeDir = `openspec/changes/${changeId}`
|
|
898
|
+
const gitCmd = `git log -1 --format="%aI" -- "${relativeChangeDir}"`
|
|
899
|
+
const { stdout } = await execAsync(gitCmd, { cwd: paths.projectPath })
|
|
900
|
+
if (stdout.trim()) {
|
|
901
|
+
updatedAt = stdout.trim()
|
|
902
|
+
} else {
|
|
903
|
+
// Fallback to file stat of tasks.md or proposal.md
|
|
904
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
905
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
906
|
+
try {
|
|
907
|
+
const stat = await import('fs/promises').then((fs) => fs.stat(tasksPath))
|
|
908
|
+
updatedAt = stat.mtime.toISOString()
|
|
909
|
+
} catch {
|
|
910
|
+
const stat = await import('fs/promises').then((fs) => fs.stat(proposalPath))
|
|
911
|
+
updatedAt = stat.mtime.toISOString()
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
} catch (err) {
|
|
915
|
+
// If all fails, use current time
|
|
916
|
+
console.error('Git log error:', err)
|
|
917
|
+
updatedAt = new Date().toISOString()
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
changes.push({
|
|
921
|
+
id: changeId,
|
|
922
|
+
title,
|
|
923
|
+
progress,
|
|
924
|
+
totalTasks,
|
|
925
|
+
completedTasks,
|
|
926
|
+
updatedAt,
|
|
927
|
+
})
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
res.json({ success: true, data: { changes } })
|
|
931
|
+
} catch (error) {
|
|
932
|
+
console.error('Error listing changes:', error)
|
|
933
|
+
res.status(500).json({ success: false, error: 'Failed to list changes' })
|
|
934
|
+
}
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
// GET /api/changes/archived - List all archived changes
|
|
938
|
+
app.get('/api/changes/archived', async (_req, res) => {
|
|
939
|
+
try {
|
|
940
|
+
const paths = await getProjectPaths()
|
|
941
|
+
if (!paths) {
|
|
942
|
+
return res.json({ success: true, data: { changes: [] } })
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const archivedChanges: Array<{
|
|
946
|
+
id: string
|
|
947
|
+
title: string
|
|
948
|
+
progress: number
|
|
949
|
+
totalTasks: number
|
|
950
|
+
completedTasks: number
|
|
951
|
+
archivedAt: string | null
|
|
952
|
+
source: 'archive' | 'archived'
|
|
953
|
+
}> = []
|
|
954
|
+
|
|
955
|
+
// Helper function to read archived change from a directory
|
|
956
|
+
async function readArchivedChange(
|
|
957
|
+
changeDir: string,
|
|
958
|
+
changeId: string,
|
|
959
|
+
source: 'archive' | 'archived'
|
|
960
|
+
) {
|
|
961
|
+
// Read proposal.md for title
|
|
962
|
+
let title = changeId
|
|
963
|
+
try {
|
|
964
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
965
|
+
const proposalContent = await readFile(proposalPath, 'utf-8')
|
|
966
|
+
const titleMatch = proposalContent.match(/^#\s+Change:\s+(.+)$/m)
|
|
967
|
+
if (titleMatch) {
|
|
968
|
+
title = titleMatch[1].trim()
|
|
969
|
+
}
|
|
970
|
+
} catch {
|
|
971
|
+
// No proposal.md, use directory name
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Read tasks.md for progress
|
|
975
|
+
let totalTasks = 0
|
|
976
|
+
let completedTasks = 0
|
|
977
|
+
try {
|
|
978
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
979
|
+
const tasksContent = await readFile(tasksPath, 'utf-8')
|
|
980
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
981
|
+
|
|
982
|
+
for (const group of parsed.groups) {
|
|
983
|
+
totalTasks += group.tasks.length
|
|
984
|
+
completedTasks += group.tasks.filter((t) => t.completed).length
|
|
985
|
+
}
|
|
986
|
+
} catch {
|
|
987
|
+
// No tasks.md
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
991
|
+
|
|
992
|
+
// Get archived date from git or file stat
|
|
993
|
+
let archivedAt: string | null = null
|
|
994
|
+
try {
|
|
995
|
+
const { stat } = await import('fs/promises')
|
|
996
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
997
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
998
|
+
try {
|
|
999
|
+
const s = await stat(proposalPath)
|
|
1000
|
+
archivedAt = s.mtime.toISOString()
|
|
1001
|
+
} catch {
|
|
1002
|
+
const s = await stat(tasksPath)
|
|
1003
|
+
archivedAt = s.mtime.toISOString()
|
|
1004
|
+
}
|
|
1005
|
+
} catch {
|
|
1006
|
+
archivedAt = null
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
id: changeId,
|
|
1011
|
+
title,
|
|
1012
|
+
progress,
|
|
1013
|
+
totalTasks,
|
|
1014
|
+
completedTasks,
|
|
1015
|
+
archivedAt,
|
|
1016
|
+
source,
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Helper to read all changes from an archive directory
|
|
1021
|
+
async function readFromArchiveDir(dir: string) {
|
|
1022
|
+
try {
|
|
1023
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
if (!entry.isDirectory()) continue
|
|
1026
|
+
const changeDir = join(dir, entry.name)
|
|
1027
|
+
const change = await readArchivedChange(changeDir, entry.name, 'archive')
|
|
1028
|
+
archivedChanges.push(change)
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
// Directory doesn't exist
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Read from all three archive locations
|
|
1036
|
+
await readFromArchiveDir(paths.archiveDir) // openspec/changes/archive/
|
|
1037
|
+
await readFromArchiveDir(paths.legacyArchiveDir) // openspec/archive/
|
|
1038
|
+
await readFromArchiveDir(paths.archivedDir) // openspec/archived/
|
|
1039
|
+
|
|
1040
|
+
// Sort by archivedAt descending (newest first)
|
|
1041
|
+
archivedChanges.sort((a, b) => {
|
|
1042
|
+
if (!a.archivedAt && !b.archivedAt) return 0
|
|
1043
|
+
if (!a.archivedAt) return 1
|
|
1044
|
+
if (!b.archivedAt) return -1
|
|
1045
|
+
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime()
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
res.json({ success: true, data: { changes: archivedChanges } })
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
console.error('Error listing archived changes:', error)
|
|
1051
|
+
res.status(500).json({ success: false, error: 'Failed to list archived changes' })
|
|
1052
|
+
}
|
|
1053
|
+
})
|
|
1054
|
+
|
|
1055
|
+
// GET /api/changes/archived/:id - Get archived change detail
|
|
1056
|
+
app.get('/api/changes/archived/:id', async (req, res) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const project = await getActiveProject()
|
|
1059
|
+
if (!project) {
|
|
1060
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const changeId = req.params.id
|
|
1064
|
+
const files: Record<string, string> = {}
|
|
1065
|
+
|
|
1066
|
+
// Remote project: use SSH plugin
|
|
1067
|
+
if (project.remote) {
|
|
1068
|
+
if (!getRemoteServerById || !listDirectory || !readRemoteFile) {
|
|
1069
|
+
return res.status(400).json({ success: false, error: 'Remote plugin not available' })
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const server = await getRemoteServerById(project.remote.serverId)
|
|
1073
|
+
if (!server) {
|
|
1074
|
+
return res.status(400).json({ success: false, error: 'Remote server not found' })
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Archive locations for remote projects
|
|
1078
|
+
const archiveLocations = [
|
|
1079
|
+
`${project.path}/openspec/changes/archive`,
|
|
1080
|
+
`${project.path}/openspec/archive`,
|
|
1081
|
+
`${project.path}/openspec/archived`,
|
|
1082
|
+
]
|
|
1083
|
+
|
|
1084
|
+
let changeDir: string | null = null
|
|
1085
|
+
|
|
1086
|
+
// Search for the change folder in archive locations
|
|
1087
|
+
for (const archiveBase of archiveLocations) {
|
|
1088
|
+
try {
|
|
1089
|
+
const listing = await listDirectory(server, archiveBase)
|
|
1090
|
+
// Look for exact match or date-prefixed match (e.g., 2026-01-17-change-id)
|
|
1091
|
+
const matchingFolder = listing.entries.find(
|
|
1092
|
+
(entry) => entry.type === 'directory' && (entry.name === changeId || entry.name.endsWith(`-${changeId}`))
|
|
1093
|
+
)
|
|
1094
|
+
if (matchingFolder) {
|
|
1095
|
+
changeDir = `${archiveBase}/${matchingFolder.name}`
|
|
1096
|
+
break
|
|
1097
|
+
}
|
|
1098
|
+
} catch {
|
|
1099
|
+
// Archive location not found, try next
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (!changeDir) {
|
|
1104
|
+
return res.status(404).json({ success: false, error: 'Archived change not found' })
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Read all .md files in the change directory
|
|
1108
|
+
try {
|
|
1109
|
+
const entries = await listDirectory(server, changeDir)
|
|
1110
|
+
for (const entry of entries.entries) {
|
|
1111
|
+
if (entry.type === 'file' && entry.name.endsWith('.md')) {
|
|
1112
|
+
try {
|
|
1113
|
+
const content = await readRemoteFile(server, `${changeDir}/${entry.name}`)
|
|
1114
|
+
files[entry.name] = content
|
|
1115
|
+
} catch {
|
|
1116
|
+
// Skip unreadable files
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
// Also check specs subdirectory
|
|
1120
|
+
if (entry.type === 'directory' && entry.name === 'specs') {
|
|
1121
|
+
try {
|
|
1122
|
+
const specsDir = `${changeDir}/specs`
|
|
1123
|
+
const specEntries = await listDirectory(server, specsDir)
|
|
1124
|
+
for (const specEntry of specEntries.entries) {
|
|
1125
|
+
if (specEntry.type === 'directory') {
|
|
1126
|
+
// Each spec is in its own folder
|
|
1127
|
+
try {
|
|
1128
|
+
const specPath = `${specsDir}/${specEntry.name}/spec.md`
|
|
1129
|
+
const content = await readRemoteFile(server, specPath)
|
|
1130
|
+
files[`specs/${specEntry.name}/spec.md`] = content
|
|
1131
|
+
} catch {
|
|
1132
|
+
// Skip unreadable spec files
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} catch {
|
|
1137
|
+
// Skip if specs dir not accessible
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
console.error('Error reading remote archived change:', error)
|
|
1143
|
+
return res.status(500).json({ success: false, error: 'Failed to read archived change' })
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return res.json({
|
|
1147
|
+
success: true,
|
|
1148
|
+
data: {
|
|
1149
|
+
id: changeId,
|
|
1150
|
+
files,
|
|
1151
|
+
},
|
|
1152
|
+
})
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Local project: use filesystem
|
|
1156
|
+
const archiveLocations = [
|
|
1157
|
+
join(project.path, 'openspec', 'changes', 'archive'),
|
|
1158
|
+
join(project.path, 'openspec', 'archive'),
|
|
1159
|
+
join(project.path, 'openspec', 'archived'),
|
|
1160
|
+
]
|
|
1161
|
+
|
|
1162
|
+
let changeDir: string | null = null
|
|
1163
|
+
|
|
1164
|
+
for (const archiveBase of archiveLocations) {
|
|
1165
|
+
const candidatePath = join(archiveBase, changeId)
|
|
1166
|
+
try {
|
|
1167
|
+
await access(candidatePath)
|
|
1168
|
+
changeDir = candidatePath
|
|
1169
|
+
break
|
|
1170
|
+
} catch {
|
|
1171
|
+
// Not found in this location, try next
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (!changeDir) {
|
|
1176
|
+
return res.status(404).json({ success: false, error: 'Archived change not found' })
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Read all files in the change directory
|
|
1180
|
+
const entries = await readdir(changeDir, { withFileTypes: true })
|
|
1181
|
+
|
|
1182
|
+
for (const entry of entries) {
|
|
1183
|
+
if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
1184
|
+
const filePath = join(changeDir, entry.name)
|
|
1185
|
+
const content = await readFile(filePath, 'utf-8')
|
|
1186
|
+
files[entry.name] = content
|
|
1187
|
+
}
|
|
1188
|
+
// Also check specs subdirectory
|
|
1189
|
+
if (entry.isDirectory() && entry.name === 'specs') {
|
|
1190
|
+
const specsDir = join(changeDir, 'specs')
|
|
1191
|
+
const specEntries = await readdir(specsDir, { withFileTypes: true })
|
|
1192
|
+
for (const specEntry of specEntries) {
|
|
1193
|
+
if (specEntry.isFile() && specEntry.name.endsWith('.md')) {
|
|
1194
|
+
const specPath = join(specsDir, specEntry.name)
|
|
1195
|
+
const content = await readFile(specPath, 'utf-8')
|
|
1196
|
+
files[`specs/${specEntry.name}`] = content
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
res.json({
|
|
1203
|
+
success: true,
|
|
1204
|
+
data: {
|
|
1205
|
+
id: changeId,
|
|
1206
|
+
files,
|
|
1207
|
+
},
|
|
1208
|
+
})
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
console.error('Error getting archived change:', error)
|
|
1211
|
+
res.status(500).json({ success: false, error: 'Failed to get archived change' })
|
|
1212
|
+
}
|
|
1213
|
+
})
|
|
1214
|
+
|
|
1215
|
+
// GET /api/changes/:id/tasks - Get tasks for a change
|
|
1216
|
+
app.get('/api/changes/:id/tasks', async (req, res) => {
|
|
1217
|
+
try {
|
|
1218
|
+
const paths = await getProjectPaths()
|
|
1219
|
+
if (!paths) {
|
|
1220
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const changeId = req.params.id
|
|
1224
|
+
const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
|
|
1225
|
+
const content = await readFile(tasksPath, 'utf-8')
|
|
1226
|
+
const parsed = parseTasksFile(changeId, content)
|
|
1227
|
+
|
|
1228
|
+
res.json({ success: true, data: parsed })
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
console.error('Error reading tasks:', error)
|
|
1231
|
+
res.status(500).json({ success: false, error: 'Failed to read tasks' })
|
|
1232
|
+
}
|
|
1233
|
+
})
|
|
1234
|
+
|
|
1235
|
+
// PATCH /api/tasks/:changeId/:taskId - Toggle task checkbox
|
|
1236
|
+
app.patch('/api/tasks/:changeId/:taskId', async (req, res) => {
|
|
1237
|
+
try {
|
|
1238
|
+
const paths = await getProjectPaths()
|
|
1239
|
+
if (!paths) {
|
|
1240
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const { changeId, taskId } = req.params
|
|
1244
|
+
const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
|
|
1245
|
+
|
|
1246
|
+
// Read current content
|
|
1247
|
+
const content = await readFile(tasksPath, 'utf-8')
|
|
1248
|
+
|
|
1249
|
+
// Toggle the task
|
|
1250
|
+
const { newContent, task } = toggleTaskInFile(content, taskId)
|
|
1251
|
+
|
|
1252
|
+
// Write back
|
|
1253
|
+
await writeFile(tasksPath, newContent, 'utf-8')
|
|
1254
|
+
|
|
1255
|
+
res.json({ success: true, data: { task } })
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
console.error('Error toggling task:', error)
|
|
1258
|
+
res.status(500).json({ success: false, error: 'Failed to toggle task' })
|
|
1259
|
+
}
|
|
1260
|
+
})
|
|
1261
|
+
|
|
1262
|
+
// GET /api/plans/:changeId/:taskId - Get detail plan
|
|
1263
|
+
app.get('/api/plans/:changeId/:taskId', async (req, res) => {
|
|
1264
|
+
try {
|
|
1265
|
+
const paths = await getProjectPaths()
|
|
1266
|
+
if (!paths) {
|
|
1267
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const { changeId, taskId } = req.params
|
|
1271
|
+
const planPath = join(paths.plansDir, changeId, `${taskId}.md`)
|
|
1272
|
+
|
|
1273
|
+
try {
|
|
1274
|
+
const content = await readFile(planPath, 'utf-8')
|
|
1275
|
+
res.json({
|
|
1276
|
+
success: true,
|
|
1277
|
+
data: { taskId, changeId, content, exists: true },
|
|
1278
|
+
})
|
|
1279
|
+
} catch {
|
|
1280
|
+
res.json({
|
|
1281
|
+
success: true,
|
|
1282
|
+
data: { taskId, changeId, content: '', exists: false },
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
console.error('Error reading plan:', error)
|
|
1287
|
+
res.status(500).json({ success: false, error: 'Failed to read plan' })
|
|
1288
|
+
}
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
// ==================== SPECS ====================
|
|
1292
|
+
// NOTE: Legacy /api/specs routes replaced by unified specsRouter (SPEC-VISIBILITY-001)
|
|
1293
|
+
// See server/routes/specs.ts for new implementation that supports both MoAI and OpenSpec formats
|
|
1294
|
+
|
|
1295
|
+
// PATCH /api/tasks/reorder - Reorder tasks within a group
|
|
1296
|
+
app.patch('/api/tasks/reorder', async (req, res) => {
|
|
1297
|
+
try {
|
|
1298
|
+
const paths = await getProjectPaths()
|
|
1299
|
+
if (!paths) {
|
|
1300
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const { changeId, groupId, taskIds } = req.body
|
|
1304
|
+
const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
|
|
1305
|
+
|
|
1306
|
+
const content = await readFile(tasksPath, 'utf-8')
|
|
1307
|
+
const lines = content.split('\n')
|
|
1308
|
+
|
|
1309
|
+
// Find the group and its tasks
|
|
1310
|
+
const groupNumber = groupId.replace('group-', '')
|
|
1311
|
+
let inGroup = false
|
|
1312
|
+
let groupStartLine = -1
|
|
1313
|
+
let groupEndLine = -1
|
|
1314
|
+
const taskLines: { id: string; line: string; lineNum: number }[] = []
|
|
1315
|
+
|
|
1316
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1317
|
+
const line = lines[i]
|
|
1318
|
+
const groupMatch = line.match(/^##\s+(\d+)\.\s+/)
|
|
1319
|
+
|
|
1320
|
+
if (groupMatch) {
|
|
1321
|
+
if (groupMatch[1] === groupNumber) {
|
|
1322
|
+
inGroup = true
|
|
1323
|
+
groupStartLine = i
|
|
1324
|
+
} else if (inGroup) {
|
|
1325
|
+
groupEndLine = i
|
|
1326
|
+
break
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (inGroup) {
|
|
1331
|
+
const taskMatch = line.match(/^-\s+\[[ xX]\]\s+(\d+\.\d+)\s+/)
|
|
1332
|
+
if (taskMatch) {
|
|
1333
|
+
const taskId = `task-${taskMatch[1].replace('.', '-')}`
|
|
1334
|
+
taskLines.push({ id: taskId, line, lineNum: i })
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
if (groupEndLine === -1) groupEndLine = lines.length
|
|
1340
|
+
|
|
1341
|
+
// Reorder task lines based on taskIds
|
|
1342
|
+
const reorderedTaskLines = taskIds
|
|
1343
|
+
.map((id: string) => {
|
|
1344
|
+
const found = taskLines.find((t) => t.id === id)
|
|
1345
|
+
return found ? found.line : null
|
|
1346
|
+
})
|
|
1347
|
+
.filter(Boolean)
|
|
1348
|
+
|
|
1349
|
+
// Rebuild the file content
|
|
1350
|
+
const beforeGroup = lines.slice(0, groupStartLine + 1)
|
|
1351
|
+
const afterGroup = lines.slice(groupEndLine)
|
|
1352
|
+
|
|
1353
|
+
// Find non-task lines in the group (empty lines, etc.)
|
|
1354
|
+
const groupNonTaskLines = lines
|
|
1355
|
+
.slice(groupStartLine + 1, groupEndLine)
|
|
1356
|
+
.filter((line) => !line.match(/^-\s+\[[ xX]\]/))
|
|
1357
|
+
.filter((line) => line.trim() === '')
|
|
1358
|
+
|
|
1359
|
+
const newContent = [
|
|
1360
|
+
...beforeGroup,
|
|
1361
|
+
'',
|
|
1362
|
+
...reorderedTaskLines,
|
|
1363
|
+
...groupNonTaskLines,
|
|
1364
|
+
...afterGroup,
|
|
1365
|
+
].join('\n')
|
|
1366
|
+
|
|
1367
|
+
await writeFile(tasksPath, newContent, 'utf-8')
|
|
1368
|
+
|
|
1369
|
+
res.json({ success: true })
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
console.error('Error reordering tasks:', error)
|
|
1372
|
+
res.status(500).json({ success: false, error: 'Failed to reorder tasks' })
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
// ==================== CLAUDE CODE EXECUTION ====================
|
|
1377
|
+
|
|
1378
|
+
// POST /api/claude/execute - Execute a task with Claude Code (SSE streaming)
|
|
1379
|
+
app.post('/api/claude/execute', async (req, res) => {
|
|
1380
|
+
try {
|
|
1381
|
+
const paths = await getProjectPaths()
|
|
1382
|
+
if (!paths) {
|
|
1383
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
const { changeId, taskId, taskTitle, context, model } = req.body
|
|
1387
|
+
// model: 'haiku' | 'sonnet' | 'opus' (default: sonnet)
|
|
1388
|
+
const project = await getActiveProject()
|
|
1389
|
+
if (!project) {
|
|
1390
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Build the prompt for Claude using OpenSpecPromptBuilder
|
|
1394
|
+
// This provides full context: CLAUDE.md, proposal.md, design.md, tasks.md, specs/
|
|
1395
|
+
let prompt: string
|
|
1396
|
+
try {
|
|
1397
|
+
const promptBuilder = new OpenSpecPromptBuilder(
|
|
1398
|
+
project.path,
|
|
1399
|
+
changeId,
|
|
1400
|
+
'single', // 단일 태스크 모드
|
|
1401
|
+
taskId,
|
|
1402
|
+
taskTitle // 태스크 제목으로도 검색 가능
|
|
1403
|
+
)
|
|
1404
|
+
prompt = await promptBuilder.build()
|
|
1405
|
+
|
|
1406
|
+
// 추가 컨텍스트가 있으면 append
|
|
1407
|
+
if (context) {
|
|
1408
|
+
prompt += `\n\n---\n\n## 추가 컨텍스트\n\n${context}`
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// 태스크 제목 명시
|
|
1412
|
+
prompt += `\n\n---\n\n**실행할 태스크**: ${taskTitle}`
|
|
1413
|
+
|
|
1414
|
+
console.log(
|
|
1415
|
+
'[Claude Execute] Built prompt with OpenSpecPromptBuilder, length:',
|
|
1416
|
+
prompt.length
|
|
1417
|
+
)
|
|
1418
|
+
} catch (buildError) {
|
|
1419
|
+
console.error('[Claude Execute] Failed to build prompt:', buildError)
|
|
1420
|
+
// 폴백: 기본 프롬프트 사용
|
|
1421
|
+
prompt = `당신은 OpenSpec 프로젝트의 태스크를 실행하는 AI입니다.
|
|
1422
|
+
|
|
1423
|
+
## 현재 작업
|
|
1424
|
+
- Change: ${changeId}
|
|
1425
|
+
- Task ID: ${taskId}
|
|
1426
|
+
- Task: ${taskTitle}
|
|
1427
|
+
|
|
1428
|
+
## 컨텍스트
|
|
1429
|
+
${context || '추가 컨텍스트 없음'}
|
|
1430
|
+
|
|
1431
|
+
## 지시사항
|
|
1432
|
+
1. 위 태스크를 완료하세요
|
|
1433
|
+
2. 필요한 파일을 읽고, 수정하거나 생성하세요
|
|
1434
|
+
3. 작업이 완료되면 결과를 요약해주세요
|
|
1435
|
+
4. 에러가 발생하면 명확하게 보고해주세요
|
|
1436
|
+
|
|
1437
|
+
작업을 시작하세요.`
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Set up SSE
|
|
1441
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
1442
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
1443
|
+
res.setHeader('Connection', 'keep-alive')
|
|
1444
|
+
res.setHeader('X-Accel-Buffering', 'no')
|
|
1445
|
+
|
|
1446
|
+
// Generate unique run ID
|
|
1447
|
+
const runId = `${changeId}-${taskId}-${Date.now()}`
|
|
1448
|
+
|
|
1449
|
+
// Send initial event
|
|
1450
|
+
res.write(`data: ${JSON.stringify({ type: 'start', runId, taskId, changeId })}\n\n`)
|
|
1451
|
+
|
|
1452
|
+
// Spawn Claude CLI process using node-pty for real TTY support
|
|
1453
|
+
// This enables stream-json output format which requires TTY
|
|
1454
|
+
// Based on official docs: https://code.claude.com/docs/en/headless
|
|
1455
|
+
console.log('[Claude Execute] Running with prompt length:', prompt.length)
|
|
1456
|
+
console.log('[Claude Execute] CWD:', project.path)
|
|
1457
|
+
|
|
1458
|
+
// Save prompt to temp file
|
|
1459
|
+
const tmpPromptPath = join('/tmp', `claude-prompt-${runId}.txt`)
|
|
1460
|
+
await writeFile(tmpPromptPath, prompt, 'utf-8')
|
|
1461
|
+
|
|
1462
|
+
// Build model argument
|
|
1463
|
+
const modelArg = model && ['haiku', 'sonnet', 'opus'].includes(model) ? `--model ${model}` : ''
|
|
1464
|
+
|
|
1465
|
+
console.log('[Claude Execute] Using model:', model || 'default (sonnet)')
|
|
1466
|
+
|
|
1467
|
+
// Use node-pty to spawn with real TTY - enables stream-json output
|
|
1468
|
+
const ptyProcess = pty.spawn(
|
|
1469
|
+
'bash',
|
|
1470
|
+
[
|
|
1471
|
+
'-c',
|
|
1472
|
+
`cat '${tmpPromptPath}' | /opt/homebrew/bin/claude -p --verbose --output-format stream-json --dangerously-skip-permissions ${modelArg}`,
|
|
1473
|
+
],
|
|
1474
|
+
{
|
|
1475
|
+
name: 'xterm-color',
|
|
1476
|
+
cols: 200,
|
|
1477
|
+
rows: 50,
|
|
1478
|
+
cwd: project.path,
|
|
1479
|
+
env: { ...process.env } as Record<string, string>,
|
|
1480
|
+
}
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
console.log('[Claude Execute] PTY Process PID:', ptyProcess.pid)
|
|
1484
|
+
|
|
1485
|
+
const taskState = {
|
|
1486
|
+
process: ptyProcess,
|
|
1487
|
+
output: [] as string[],
|
|
1488
|
+
status: 'running' as 'running' | 'completed' | 'error',
|
|
1489
|
+
startedAt: new Date(),
|
|
1490
|
+
}
|
|
1491
|
+
runningTasks.set(runId, taskState)
|
|
1492
|
+
|
|
1493
|
+
let buffer = ''
|
|
1494
|
+
|
|
1495
|
+
ptyProcess.onData((data: string) => {
|
|
1496
|
+
console.log('[Claude Execute] pty data:', data.slice(0, 200))
|
|
1497
|
+
buffer += data
|
|
1498
|
+
const lines = buffer.split('\n')
|
|
1499
|
+
buffer = lines.pop() || ''
|
|
1500
|
+
|
|
1501
|
+
for (const line of lines) {
|
|
1502
|
+
if (!line.trim()) continue
|
|
1503
|
+
|
|
1504
|
+
// Remove ANSI escape codes that PTY might add
|
|
1505
|
+
// eslint-disable-next-line no-control-regex
|
|
1506
|
+
const cleanLine = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').trim()
|
|
1507
|
+
if (!cleanLine) continue
|
|
1508
|
+
|
|
1509
|
+
try {
|
|
1510
|
+
const parsed = JSON.parse(cleanLine)
|
|
1511
|
+
taskState.output.push(cleanLine)
|
|
1512
|
+
|
|
1513
|
+
// Forward to client
|
|
1514
|
+
res.write(`data: ${JSON.stringify({ type: 'output', data: parsed })}\n\n`)
|
|
1515
|
+
|
|
1516
|
+
// Check if this is the final result - terminate process
|
|
1517
|
+
if (parsed.type === 'result') {
|
|
1518
|
+
console.log('[Claude Execute] Result received, terminating process')
|
|
1519
|
+
// Small delay to ensure the result is sent to client
|
|
1520
|
+
setTimeout(() => {
|
|
1521
|
+
try {
|
|
1522
|
+
ptyProcess.kill()
|
|
1523
|
+
} catch {
|
|
1524
|
+
// Process might already be dead
|
|
1525
|
+
}
|
|
1526
|
+
}, 100)
|
|
1527
|
+
}
|
|
1528
|
+
} catch {
|
|
1529
|
+
// Non-JSON output, send as text
|
|
1530
|
+
res.write(`data: ${JSON.stringify({ type: 'text', content: cleanLine })}\n\n`)
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
// node-pty uses onExit instead of 'close' event
|
|
1536
|
+
ptyProcess.onExit(async ({ exitCode: code }) => {
|
|
1537
|
+
const status = code === 0 ? 'completed' : 'error'
|
|
1538
|
+
taskState.status = status
|
|
1539
|
+
|
|
1540
|
+
// Clean up temp prompt file
|
|
1541
|
+
try {
|
|
1542
|
+
const { unlink } = await import('fs/promises')
|
|
1543
|
+
await unlink(tmpPromptPath)
|
|
1544
|
+
} catch {
|
|
1545
|
+
// Ignore cleanup errors
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Save execution log
|
|
1549
|
+
try {
|
|
1550
|
+
const logsDir = join(project.path, '.zyflow', 'logs', changeId)
|
|
1551
|
+
await mkdir(logsDir, { recursive: true })
|
|
1552
|
+
const logPath = join(logsDir, `${taskId}-${Date.now()}.json`)
|
|
1553
|
+
await writeFile(
|
|
1554
|
+
logPath,
|
|
1555
|
+
JSON.stringify(
|
|
1556
|
+
{
|
|
1557
|
+
runId,
|
|
1558
|
+
changeId,
|
|
1559
|
+
taskId,
|
|
1560
|
+
taskTitle,
|
|
1561
|
+
status,
|
|
1562
|
+
startedAt: taskState.startedAt,
|
|
1563
|
+
completedAt: new Date(),
|
|
1564
|
+
output: taskState.output,
|
|
1565
|
+
},
|
|
1566
|
+
null,
|
|
1567
|
+
2
|
|
1568
|
+
)
|
|
1569
|
+
)
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
console.error('Failed to save log:', err)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Auto-complete task on successful execution is disabled for now
|
|
1575
|
+
// The task completion should be done by Claude itself via tasks.md update
|
|
1576
|
+
const taskAutoCompleted = false
|
|
1577
|
+
|
|
1578
|
+
res.write(
|
|
1579
|
+
`data: ${JSON.stringify({
|
|
1580
|
+
type: 'complete',
|
|
1581
|
+
runId,
|
|
1582
|
+
status,
|
|
1583
|
+
exitCode: code,
|
|
1584
|
+
taskAutoCompleted,
|
|
1585
|
+
})}\n\n`
|
|
1586
|
+
)
|
|
1587
|
+
res.end()
|
|
1588
|
+
|
|
1589
|
+
// Cleanup after 5 minutes
|
|
1590
|
+
setTimeout(() => runningTasks.delete(runId), 5 * 60 * 1000)
|
|
1591
|
+
})
|
|
1592
|
+
|
|
1593
|
+
// Handle client disconnect
|
|
1594
|
+
req.on('close', () => {
|
|
1595
|
+
if (taskState.status === 'running') {
|
|
1596
|
+
ptyProcess.kill()
|
|
1597
|
+
}
|
|
1598
|
+
})
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
console.error('Error executing Claude:', error)
|
|
1601
|
+
res.status(500).json({ success: false, error: 'Failed to execute Claude' })
|
|
1602
|
+
}
|
|
1603
|
+
})
|
|
1604
|
+
|
|
1605
|
+
// GET /api/claude/status/:runId - Get status of a running task
|
|
1606
|
+
app.get('/api/claude/status/:runId', (req, res) => {
|
|
1607
|
+
const task = runningTasks.get(req.params.runId)
|
|
1608
|
+
if (!task) {
|
|
1609
|
+
return res.json({ success: true, data: { status: 'not_found' } })
|
|
1610
|
+
}
|
|
1611
|
+
res.json({
|
|
1612
|
+
success: true,
|
|
1613
|
+
data: {
|
|
1614
|
+
status: task.status,
|
|
1615
|
+
startedAt: task.startedAt,
|
|
1616
|
+
outputLength: task.output.length,
|
|
1617
|
+
},
|
|
1618
|
+
})
|
|
1619
|
+
})
|
|
1620
|
+
|
|
1621
|
+
// POST /api/claude/stop/:runId - Stop a running task
|
|
1622
|
+
app.post('/api/claude/stop/:runId', (req, res) => {
|
|
1623
|
+
const task = runningTasks.get(req.params.runId)
|
|
1624
|
+
if (!task || task.status !== 'running') {
|
|
1625
|
+
return res.json({ success: false, error: 'Task not running' })
|
|
1626
|
+
}
|
|
1627
|
+
task.process.kill('SIGTERM')
|
|
1628
|
+
task.status = 'error'
|
|
1629
|
+
res.json({ success: true })
|
|
1630
|
+
})
|
|
1631
|
+
|
|
1632
|
+
// GET /api/claude/logs/:changeId - Get execution logs for a change
|
|
1633
|
+
app.get('/api/claude/logs/:changeId', async (req, res) => {
|
|
1634
|
+
try {
|
|
1635
|
+
const paths = await getProjectPaths()
|
|
1636
|
+
if (!paths) {
|
|
1637
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const project = await getActiveProject()
|
|
1641
|
+
if (!project) {
|
|
1642
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const logsDir = join(project.path, '.zyflow', 'logs', req.params.changeId)
|
|
1646
|
+
let files: string[] = []
|
|
1647
|
+
try {
|
|
1648
|
+
const entries = await readdir(logsDir)
|
|
1649
|
+
files = entries.filter((f) => f.endsWith('.json'))
|
|
1650
|
+
} catch {
|
|
1651
|
+
// No logs directory yet
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const logs = []
|
|
1655
|
+
for (const file of files.slice(-20)) {
|
|
1656
|
+
// Last 20 logs
|
|
1657
|
+
try {
|
|
1658
|
+
const content = await readFile(join(logsDir, file), 'utf-8')
|
|
1659
|
+
logs.push(JSON.parse(content))
|
|
1660
|
+
} catch {
|
|
1661
|
+
// Skip invalid logs
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
res.json({ success: true, data: { logs } })
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
console.error('Error reading logs:', error)
|
|
1668
|
+
res.status(500).json({ success: false, error: 'Failed to read logs' })
|
|
1669
|
+
}
|
|
1670
|
+
})
|
|
1671
|
+
|
|
1672
|
+
// ==================== TASK MANAGEMENT (SQLite) ====================
|
|
1673
|
+
|
|
1674
|
+
// Initialize task DB - 항상 zyflow 중앙 DB 사용
|
|
1675
|
+
// 모든 프로젝트의 데이터는 zyflow/.zyflow/tasks.db에 저장됨
|
|
1676
|
+
async function initTaskDb() {
|
|
1677
|
+
// process.cwd()의 DB를 사용 (zyflow 디렉토리)
|
|
1678
|
+
initDb()
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// GET /api/tasks - List all tasks
|
|
1682
|
+
app.get('/api/tasks', async (req, res) => {
|
|
1683
|
+
try {
|
|
1684
|
+
await initTaskDb()
|
|
1685
|
+
const { status, priority, tags, kanban } = req.query
|
|
1686
|
+
|
|
1687
|
+
if (kanban === 'true') {
|
|
1688
|
+
const tasksByStatus = getTasksByStatus()
|
|
1689
|
+
return res.json({ success: true, data: { tasksByStatus } })
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
const tasks = listTasks({
|
|
1693
|
+
status: status as TaskStatus | undefined,
|
|
1694
|
+
priority: priority as TaskPriority | undefined,
|
|
1695
|
+
tags: tags ? (tags as string).split(',') : undefined,
|
|
1696
|
+
})
|
|
1697
|
+
|
|
1698
|
+
res.json({ success: true, data: { tasks } })
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
console.error('Error listing tasks:', error)
|
|
1701
|
+
res.status(500).json({ success: false, error: 'Failed to list tasks' })
|
|
1702
|
+
}
|
|
1703
|
+
})
|
|
1704
|
+
|
|
1705
|
+
// POST /api/tasks - Create a new task
|
|
1706
|
+
app.post('/api/tasks', async (req, res) => {
|
|
1707
|
+
try {
|
|
1708
|
+
await initTaskDb()
|
|
1709
|
+
const { title, description, status, priority, tags, assignee } = req.body
|
|
1710
|
+
|
|
1711
|
+
if (!title) {
|
|
1712
|
+
return res.status(400).json({ success: false, error: 'Title is required' })
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const project = await getActiveProject()
|
|
1716
|
+
const task = createTask({
|
|
1717
|
+
title,
|
|
1718
|
+
description,
|
|
1719
|
+
status: status as TaskStatus,
|
|
1720
|
+
priority: priority as TaskPriority,
|
|
1721
|
+
tags,
|
|
1722
|
+
assignee,
|
|
1723
|
+
projectId: project?.id || 'default',
|
|
1724
|
+
})
|
|
1725
|
+
|
|
1726
|
+
res.json({ success: true, data: { task } })
|
|
1727
|
+
} catch (error) {
|
|
1728
|
+
console.error('Error creating task:', error)
|
|
1729
|
+
res.status(500).json({ success: false, error: 'Failed to create task' })
|
|
1730
|
+
}
|
|
1731
|
+
})
|
|
1732
|
+
|
|
1733
|
+
// GET /api/tasks/archived - Get archived tasks with pagination
|
|
1734
|
+
// NOTE: This route MUST be defined before /api/tasks/:id to avoid 'archived' being matched as :id
|
|
1735
|
+
app.get('/api/tasks/archived', async (req, res) => {
|
|
1736
|
+
try {
|
|
1737
|
+
await initTaskDb()
|
|
1738
|
+
const page = parseInt(req.query.page as string, 10) || 1
|
|
1739
|
+
const limit = parseInt(req.query.limit as string, 10) || 20
|
|
1740
|
+
const search = req.query.search as string | undefined
|
|
1741
|
+
|
|
1742
|
+
// Get archived tasks only
|
|
1743
|
+
let archivedTasks = listTasks({
|
|
1744
|
+
status: 'archived',
|
|
1745
|
+
includeArchived: true,
|
|
1746
|
+
orderBy: 'updatedAt',
|
|
1747
|
+
orderDir: 'desc',
|
|
1748
|
+
})
|
|
1749
|
+
|
|
1750
|
+
// Filter by search query
|
|
1751
|
+
if (search) {
|
|
1752
|
+
const query = search.toLowerCase()
|
|
1753
|
+
archivedTasks = archivedTasks.filter(
|
|
1754
|
+
(task) =>
|
|
1755
|
+
task.title.toLowerCase().includes(query) ||
|
|
1756
|
+
task.description?.toLowerCase().includes(query) ||
|
|
1757
|
+
task.id.toString().includes(query)
|
|
1758
|
+
)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
const total = archivedTasks.length
|
|
1762
|
+
const totalPages = Math.ceil(total / limit)
|
|
1763
|
+
const offset = (page - 1) * limit
|
|
1764
|
+
const tasks = archivedTasks.slice(offset, offset + limit)
|
|
1765
|
+
|
|
1766
|
+
res.json({
|
|
1767
|
+
success: true,
|
|
1768
|
+
data: {
|
|
1769
|
+
tasks,
|
|
1770
|
+
pagination: {
|
|
1771
|
+
page,
|
|
1772
|
+
limit,
|
|
1773
|
+
total,
|
|
1774
|
+
totalPages,
|
|
1775
|
+
},
|
|
1776
|
+
},
|
|
1777
|
+
})
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
console.error('Error getting archived tasks:', error)
|
|
1780
|
+
res.status(500).json({ success: false, error: 'Failed to get archived tasks' })
|
|
1781
|
+
}
|
|
1782
|
+
})
|
|
1783
|
+
|
|
1784
|
+
// GET /api/tasks/search - Search tasks
|
|
1785
|
+
// NOTE: This route MUST be defined before /api/tasks/:id
|
|
1786
|
+
app.get('/api/tasks/search', async (req, res) => {
|
|
1787
|
+
try {
|
|
1788
|
+
await initTaskDb()
|
|
1789
|
+
const { q, status, priority, limit } = req.query
|
|
1790
|
+
|
|
1791
|
+
if (!q) {
|
|
1792
|
+
return res.status(400).json({ success: false, error: 'Query is required' })
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const tasks = searchTasks(q as string, {
|
|
1796
|
+
status: status as string | undefined,
|
|
1797
|
+
priority: priority as string | undefined,
|
|
1798
|
+
limit: limit ? parseInt(limit as string, 10) : undefined,
|
|
1799
|
+
})
|
|
1800
|
+
|
|
1801
|
+
res.json({ success: true, data: { tasks } })
|
|
1802
|
+
} catch (error) {
|
|
1803
|
+
console.error('Error searching tasks:', error)
|
|
1804
|
+
res.status(500).json({ success: false, error: 'Failed to search tasks' })
|
|
1805
|
+
}
|
|
1806
|
+
})
|
|
1807
|
+
|
|
1808
|
+
// GET /api/tasks/:id - Get a single task
|
|
1809
|
+
app.get('/api/tasks/:id', async (req, res) => {
|
|
1810
|
+
try {
|
|
1811
|
+
await initTaskDb()
|
|
1812
|
+
const task = getTask(req.params.id)
|
|
1813
|
+
|
|
1814
|
+
if (!task) {
|
|
1815
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
res.json({ success: true, data: { task } })
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
console.error('Error getting task:', error)
|
|
1821
|
+
res.status(500).json({ success: false, error: 'Failed to get task' })
|
|
1822
|
+
}
|
|
1823
|
+
})
|
|
1824
|
+
|
|
1825
|
+
// PATCH /api/tasks/:id - Update a task
|
|
1826
|
+
app.patch('/api/tasks/:id', async (req, res) => {
|
|
1827
|
+
try {
|
|
1828
|
+
await initTaskDb()
|
|
1829
|
+
const { title, description, status, priority, tags, assignee, order } = req.body
|
|
1830
|
+
|
|
1831
|
+
const task = updateTask(req.params.id, {
|
|
1832
|
+
title,
|
|
1833
|
+
description,
|
|
1834
|
+
status: status as TaskStatus,
|
|
1835
|
+
priority: priority as TaskPriority,
|
|
1836
|
+
tags,
|
|
1837
|
+
assignee,
|
|
1838
|
+
order,
|
|
1839
|
+
})
|
|
1840
|
+
|
|
1841
|
+
if (!task) {
|
|
1842
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
res.json({ success: true, data: { task } })
|
|
1846
|
+
} catch (error) {
|
|
1847
|
+
console.error('Error updating task:', error)
|
|
1848
|
+
res.status(500).json({ success: false, error: 'Failed to update task' })
|
|
1849
|
+
}
|
|
1850
|
+
})
|
|
1851
|
+
|
|
1852
|
+
// DELETE /api/tasks/:id - Delete a task
|
|
1853
|
+
app.delete('/api/tasks/:id', async (req, res) => {
|
|
1854
|
+
try {
|
|
1855
|
+
await initTaskDb()
|
|
1856
|
+
const deleted = deleteTask(req.params.id)
|
|
1857
|
+
|
|
1858
|
+
if (!deleted) {
|
|
1859
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
res.json({ success: true })
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
console.error('Error deleting task:', error)
|
|
1865
|
+
res.status(500).json({ success: false, error: 'Failed to delete task' })
|
|
1866
|
+
}
|
|
1867
|
+
})
|
|
1868
|
+
|
|
1869
|
+
// POST /api/tasks/:id/archive - Archive a task
|
|
1870
|
+
app.post('/api/tasks/:id/archive', async (req, res) => {
|
|
1871
|
+
try {
|
|
1872
|
+
await initTaskDb()
|
|
1873
|
+
const task = archiveTask(req.params.id)
|
|
1874
|
+
|
|
1875
|
+
if (!task) {
|
|
1876
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
res.json({ success: true, data: { task } })
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
console.error('Error archiving task:', error)
|
|
1882
|
+
res.status(500).json({ success: false, error: 'Failed to archive task' })
|
|
1883
|
+
}
|
|
1884
|
+
})
|
|
1885
|
+
|
|
1886
|
+
// POST /api/tasks/:id/unarchive - Unarchive a task
|
|
1887
|
+
app.post('/api/tasks/:id/unarchive', async (req, res) => {
|
|
1888
|
+
try {
|
|
1889
|
+
await initTaskDb()
|
|
1890
|
+
const task = unarchiveTask(req.params.id)
|
|
1891
|
+
|
|
1892
|
+
if (!task) {
|
|
1893
|
+
return res.status(404).json({ success: false, error: 'Task not found or not archived' })
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
res.json({ success: true, data: { task } })
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
console.error('Error unarchiving task:', error)
|
|
1899
|
+
res.status(500).json({ success: false, error: 'Failed to unarchive task' })
|
|
1900
|
+
}
|
|
1901
|
+
})
|
|
1902
|
+
|
|
1903
|
+
// ==================== FLOW API (DB 기반 Change 관리) ====================
|
|
1904
|
+
|
|
1905
|
+
const STAGES: Stage[] = ['spec', 'changes', 'task', 'code', 'test', 'commit', 'docs']
|
|
1906
|
+
|
|
1907
|
+
// Helper: Change의 stages 집계 정보 계산
|
|
1908
|
+
function getChangeStages(changeId: string, projectId?: string) {
|
|
1909
|
+
const sqlite = getSqlite()
|
|
1910
|
+
const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
|
|
1911
|
+
spec: { total: 0, completed: 0, tasks: [] },
|
|
1912
|
+
changes: { total: 0, completed: 0, tasks: [] },
|
|
1913
|
+
task: { total: 0, completed: 0, tasks: [] },
|
|
1914
|
+
code: { total: 0, completed: 0, tasks: [] },
|
|
1915
|
+
test: { total: 0, completed: 0, tasks: [] },
|
|
1916
|
+
commit: { total: 0, completed: 0, tasks: [] },
|
|
1917
|
+
docs: { total: 0, completed: 0, tasks: [] },
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
// project_id 필터 추가: 같은 changeId가 여러 프로젝트에 있을 수 있으므로 project_id로 필터링
|
|
1921
|
+
const tasks = sqlite
|
|
1922
|
+
.prepare(
|
|
1923
|
+
projectId
|
|
1924
|
+
? `SELECT * FROM tasks WHERE change_id = ? AND project_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
|
|
1925
|
+
: `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
|
|
1926
|
+
)
|
|
1927
|
+
.all(projectId ? [changeId, projectId] : [changeId]) as Array<{
|
|
1928
|
+
id: number
|
|
1929
|
+
change_id: string
|
|
1930
|
+
stage: Stage
|
|
1931
|
+
title: string
|
|
1932
|
+
description: string | null
|
|
1933
|
+
status: string
|
|
1934
|
+
priority: string
|
|
1935
|
+
tags: string | null
|
|
1936
|
+
assignee: string | null
|
|
1937
|
+
order: number
|
|
1938
|
+
group_title: string | null
|
|
1939
|
+
group_order: number
|
|
1940
|
+
task_order: number
|
|
1941
|
+
major_title: string | null
|
|
1942
|
+
sub_order: number | null
|
|
1943
|
+
display_id: string | null
|
|
1944
|
+
created_at: number
|
|
1945
|
+
updated_at: number
|
|
1946
|
+
archived_at: number | null
|
|
1947
|
+
}>
|
|
1948
|
+
|
|
1949
|
+
for (const task of tasks) {
|
|
1950
|
+
const stage = task.stage as Stage
|
|
1951
|
+
stages[stage].total++
|
|
1952
|
+
if (task.status === 'done') {
|
|
1953
|
+
stages[stage].completed++
|
|
1954
|
+
}
|
|
1955
|
+
stages[stage].tasks.push({
|
|
1956
|
+
id: task.id,
|
|
1957
|
+
changeId: task.change_id,
|
|
1958
|
+
stage: task.stage,
|
|
1959
|
+
title: task.title,
|
|
1960
|
+
description: task.description,
|
|
1961
|
+
status: task.status,
|
|
1962
|
+
priority: task.priority,
|
|
1963
|
+
tags: task.tags ? JSON.parse(task.tags) : [],
|
|
1964
|
+
assignee: task.assignee,
|
|
1965
|
+
order: task.order,
|
|
1966
|
+
groupTitle: task.group_title,
|
|
1967
|
+
groupOrder: task.group_order,
|
|
1968
|
+
taskOrder: task.task_order,
|
|
1969
|
+
majorTitle: task.major_title,
|
|
1970
|
+
subOrder: task.sub_order,
|
|
1971
|
+
displayId: task.display_id,
|
|
1972
|
+
createdAt: new Date(task.created_at).toISOString(),
|
|
1973
|
+
updatedAt: new Date(task.updated_at).toISOString(),
|
|
1974
|
+
archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
|
|
1975
|
+
})
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
return stages
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// Helper: Change 진행률 계산
|
|
1982
|
+
function calculateProgress(stages: Record<Stage, { total: number; completed: number }>): number {
|
|
1983
|
+
let totalTasks = 0
|
|
1984
|
+
let completedTasks = 0
|
|
1985
|
+
for (const stage of STAGES) {
|
|
1986
|
+
totalTasks += stages[stage].total
|
|
1987
|
+
completedTasks += stages[stage].completed
|
|
1988
|
+
}
|
|
1989
|
+
return totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
// Helper: 현재 stage 결정 (가장 먼저 미완료된 stage)
|
|
1993
|
+
function determineCurrentStage(stages: Record<Stage, { total: number; completed: number }>): Stage {
|
|
1994
|
+
for (const stage of STAGES) {
|
|
1995
|
+
if (stages[stage].total > stages[stage].completed) {
|
|
1996
|
+
return stage
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
return 'docs' // 모든 stage 완료시
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// GET /api/flow/changes/counts - 프로젝트별 Change 수 (상태별 집계)
|
|
2003
|
+
app.get('/api/flow/changes/counts', async (req, res) => {
|
|
2004
|
+
try {
|
|
2005
|
+
await initTaskDb()
|
|
2006
|
+
const config = await loadConfig()
|
|
2007
|
+
const sqlite = getSqlite()
|
|
2008
|
+
|
|
2009
|
+
// 쿼리 파라미터에서 상태 필터링 옵션 가져오기
|
|
2010
|
+
const { status } = req.query // 'active', 'completed', 'all' (기본값: 'active')
|
|
2011
|
+
|
|
2012
|
+
const counts: Record<string, number> = {}
|
|
2013
|
+
const detailedCounts: Record<string, { active: number; completed: number; total: number }> = {}
|
|
2014
|
+
|
|
2015
|
+
// 단일 쿼리로 모든 프로젝트의 집계 데이터 가져오기 (성능 최적화)
|
|
2016
|
+
const projectIds = config.projects.map((p) => p.id)
|
|
2017
|
+
const placeholders = projectIds.map(() => '?').join(',')
|
|
2018
|
+
|
|
2019
|
+
// 상세 집계 쿼리 (인덱스 활용)
|
|
2020
|
+
const detailedResults = sqlite
|
|
2021
|
+
.prepare(
|
|
2022
|
+
`
|
|
2023
|
+
SELECT
|
|
2024
|
+
project_id,
|
|
2025
|
+
SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
|
|
2026
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
|
|
2027
|
+
COUNT(*) as total
|
|
2028
|
+
FROM changes
|
|
2029
|
+
WHERE project_id IN (${placeholders})
|
|
2030
|
+
GROUP BY project_id
|
|
2031
|
+
`
|
|
2032
|
+
)
|
|
2033
|
+
.all(...projectIds) as Array<{
|
|
2034
|
+
project_id: string
|
|
2035
|
+
active: number
|
|
2036
|
+
completed: number
|
|
2037
|
+
total: number
|
|
2038
|
+
}>
|
|
2039
|
+
|
|
2040
|
+
// 결과 매핑
|
|
2041
|
+
for (const project of config.projects) {
|
|
2042
|
+
const projectResult = detailedResults.find((r) => r.project_id === project.id)
|
|
2043
|
+
|
|
2044
|
+
// 기존 단일 집계 (하위 호환성)
|
|
2045
|
+
let count = 0
|
|
2046
|
+
if (status === 'active') {
|
|
2047
|
+
count = projectResult?.active ?? 0
|
|
2048
|
+
} else if (status === 'completed') {
|
|
2049
|
+
count = projectResult?.completed ?? 0
|
|
2050
|
+
} else {
|
|
2051
|
+
count = projectResult?.total ?? 0
|
|
2052
|
+
}
|
|
2053
|
+
counts[project.id] = count
|
|
2054
|
+
|
|
2055
|
+
// 상세 집계
|
|
2056
|
+
detailedCounts[project.id] = {
|
|
2057
|
+
active: projectResult?.active ?? 0,
|
|
2058
|
+
completed: projectResult?.completed ?? 0,
|
|
2059
|
+
total: projectResult?.total ?? 0,
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// 항상 상세 정보를 포함하여 반환 (하위 호환성 유지)
|
|
2064
|
+
const responseData = { counts, detailed: detailedCounts }
|
|
2065
|
+
|
|
2066
|
+
res.json({ success: true, data: responseData })
|
|
2067
|
+
} catch (error) {
|
|
2068
|
+
console.error('Error getting change counts:', error)
|
|
2069
|
+
res.status(500).json({
|
|
2070
|
+
success: false,
|
|
2071
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
2072
|
+
})
|
|
2073
|
+
}
|
|
2074
|
+
})
|
|
2075
|
+
|
|
2076
|
+
// GET /api/flow/changes - Flow Changes 목록 (모든 프로젝트)
|
|
2077
|
+
app.get('/api/flow/changes', async (_req, res) => {
|
|
2078
|
+
try {
|
|
2079
|
+
await initTaskDb()
|
|
2080
|
+
const config = await loadConfig()
|
|
2081
|
+
|
|
2082
|
+
if (config.projects.length === 0) {
|
|
2083
|
+
return res.json({ success: true, data: { changes: [] } })
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
const sqlite = getSqlite()
|
|
2087
|
+
const allChanges: Array<{
|
|
2088
|
+
id: string
|
|
2089
|
+
projectId: string
|
|
2090
|
+
projectName: string
|
|
2091
|
+
title: string
|
|
2092
|
+
specPath: string | null
|
|
2093
|
+
status: string
|
|
2094
|
+
currentStage: string
|
|
2095
|
+
progress: number
|
|
2096
|
+
createdAt: string
|
|
2097
|
+
updatedAt: string
|
|
2098
|
+
stages: ReturnType<typeof getChangeStages>
|
|
2099
|
+
type: 'openspec' | 'moai-spec'
|
|
2100
|
+
}> = []
|
|
2101
|
+
|
|
2102
|
+
// Scan all projects
|
|
2103
|
+
for (const project of config.projects) {
|
|
2104
|
+
// Get OpenSpec changes from DB
|
|
2105
|
+
const dbChanges = sqlite
|
|
2106
|
+
.prepare(
|
|
2107
|
+
`
|
|
2108
|
+
SELECT * FROM changes
|
|
2109
|
+
WHERE project_id = ? AND status != 'archived'
|
|
2110
|
+
ORDER BY updated_at DESC
|
|
2111
|
+
`
|
|
2112
|
+
)
|
|
2113
|
+
.all(project.id) as Array<{
|
|
2114
|
+
id: string
|
|
2115
|
+
project_id: string
|
|
2116
|
+
title: string
|
|
2117
|
+
spec_path: string | null
|
|
2118
|
+
status: ChangeStatus
|
|
2119
|
+
current_stage: Stage
|
|
2120
|
+
progress: number
|
|
2121
|
+
created_at: number
|
|
2122
|
+
updated_at: number
|
|
2123
|
+
}>
|
|
2124
|
+
|
|
2125
|
+
for (const c of dbChanges) {
|
|
2126
|
+
const stages = getChangeStages(c.id, project.path)
|
|
2127
|
+
const progress = calculateProgress(stages)
|
|
2128
|
+
const currentStage = determineCurrentStage(stages)
|
|
2129
|
+
|
|
2130
|
+
allChanges.push({
|
|
2131
|
+
id: c.id,
|
|
2132
|
+
projectId: c.project_id,
|
|
2133
|
+
projectName: project.name,
|
|
2134
|
+
title: c.title,
|
|
2135
|
+
specPath: c.spec_path,
|
|
2136
|
+
status: c.status,
|
|
2137
|
+
currentStage,
|
|
2138
|
+
progress,
|
|
2139
|
+
createdAt: new Date(c.created_at).toISOString(),
|
|
2140
|
+
updatedAt: new Date(c.updated_at).toISOString(),
|
|
2141
|
+
stages,
|
|
2142
|
+
type: 'openspec' as const,
|
|
2143
|
+
})
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// Scan MoAI SPECs from .moai/specs/ directory
|
|
2147
|
+
try {
|
|
2148
|
+
const moaiSpecs = await scanMoaiSpecs(project.path)
|
|
2149
|
+
for (const spec of moaiSpecs) {
|
|
2150
|
+
if (spec.status === 'archived') continue
|
|
2151
|
+
|
|
2152
|
+
allChanges.push({
|
|
2153
|
+
id: spec.id,
|
|
2154
|
+
projectId: project.id,
|
|
2155
|
+
projectName: project.name,
|
|
2156
|
+
title: spec.title,
|
|
2157
|
+
specPath: `.moai/specs/${spec.id}`,
|
|
2158
|
+
status: spec.status === 'complete' ? 'done' : spec.status === 'active' ? 'in_progress' : 'pending',
|
|
2159
|
+
currentStage: spec.status === 'complete' ? 'sync' : spec.status === 'active' ? 'run' : 'plan',
|
|
2160
|
+
progress: spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0,
|
|
2161
|
+
createdAt: spec.createdAt || new Date().toISOString(),
|
|
2162
|
+
updatedAt: spec.updatedAt || new Date().toISOString(),
|
|
2163
|
+
stages: [],
|
|
2164
|
+
type: 'moai-spec' as const,
|
|
2165
|
+
})
|
|
2166
|
+
}
|
|
2167
|
+
} catch (err) {
|
|
2168
|
+
console.warn(`Failed to scan MoAI SPECs for ${project.name}:`, err)
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// Sort by updatedAt (most recent first)
|
|
2173
|
+
const changes = allChanges.sort((a, b) => {
|
|
2174
|
+
const aTime = new Date(a.updatedAt).getTime()
|
|
2175
|
+
const bTime = new Date(b.updatedAt).getTime()
|
|
2176
|
+
return bTime - aTime
|
|
2177
|
+
})
|
|
2178
|
+
|
|
2179
|
+
res.json({ success: true, data: { changes } })
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
console.error('Error listing flow changes:', error)
|
|
2182
|
+
res.status(500).json({ success: false, error: 'Failed to list flow changes' })
|
|
2183
|
+
}
|
|
2184
|
+
})
|
|
2185
|
+
|
|
2186
|
+
// GET /api/flow/changes/:id - Flow Change 상세 (stages 포함)
|
|
2187
|
+
app.get('/api/flow/changes/:id', async (req, res) => {
|
|
2188
|
+
try {
|
|
2189
|
+
await initTaskDb()
|
|
2190
|
+
const config = await loadConfig()
|
|
2191
|
+
|
|
2192
|
+
// 활성 프로젝트에서 우선 조회 (같은 changeId가 여러 프로젝트에 있을 수 있음)
|
|
2193
|
+
const activeProjectId = config.activeProjectId
|
|
2194
|
+
const sqlite = getSqlite()
|
|
2195
|
+
|
|
2196
|
+
let change = activeProjectId
|
|
2197
|
+
? (sqlite
|
|
2198
|
+
.prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
|
|
2199
|
+
.get(req.params.id, activeProjectId) as {
|
|
2200
|
+
id: string
|
|
2201
|
+
project_id: string
|
|
2202
|
+
title: string
|
|
2203
|
+
spec_path: string | null
|
|
2204
|
+
status: ChangeStatus
|
|
2205
|
+
current_stage: Stage
|
|
2206
|
+
progress: number
|
|
2207
|
+
created_at: number
|
|
2208
|
+
updated_at: number
|
|
2209
|
+
} | undefined)
|
|
2210
|
+
: undefined
|
|
2211
|
+
|
|
2212
|
+
// 활성 프로젝트에 없으면 전체에서 조회 (fallback)
|
|
2213
|
+
if (!change) {
|
|
2214
|
+
change = sqlite
|
|
2215
|
+
.prepare(`SELECT * FROM changes WHERE id = ?`)
|
|
2216
|
+
.get(req.params.id) as typeof change
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
if (!change) {
|
|
2220
|
+
return res.status(404).json({ success: false, error: 'Change not found' })
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// Change가 속한 프로젝트 경로 찾기
|
|
2224
|
+
const project = config.projects.find((p) => p.id === change.project_id)
|
|
2225
|
+
if (!project) {
|
|
2226
|
+
return res.status(404).json({ success: false, error: 'Project not found for this change' })
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// project_id를 전달하여 해당 프로젝트의 Tasks만 가져오기
|
|
2230
|
+
const stages = getChangeStages(change.id, change.project_id)
|
|
2231
|
+
const progress = calculateProgress(stages)
|
|
2232
|
+
const currentStage = determineCurrentStage(stages)
|
|
2233
|
+
|
|
2234
|
+
// Git에서 실제 날짜 가져오기
|
|
2235
|
+
let gitCreatedAt: string | null = null
|
|
2236
|
+
let gitUpdatedAt: string | null = null
|
|
2237
|
+
try {
|
|
2238
|
+
const relativeChangeDir = `openspec/changes/${change.id}`
|
|
2239
|
+
// 최신 커밋 날짜 (수정일)
|
|
2240
|
+
const { stdout: updatedStdout } = await execAsync(
|
|
2241
|
+
`git log -1 --format="%aI" -- "${relativeChangeDir}"`,
|
|
2242
|
+
{ cwd: project.path }
|
|
2243
|
+
)
|
|
2244
|
+
if (updatedStdout.trim()) {
|
|
2245
|
+
gitUpdatedAt = updatedStdout.trim()
|
|
2246
|
+
}
|
|
2247
|
+
// 최초 커밋 날짜 (생성일)
|
|
2248
|
+
const { stdout: createdStdout } = await execAsync(
|
|
2249
|
+
`git log --diff-filter=A --format="%aI" -- "${relativeChangeDir}" | tail -1`,
|
|
2250
|
+
{ cwd: project.path }
|
|
2251
|
+
)
|
|
2252
|
+
if (createdStdout.trim()) {
|
|
2253
|
+
gitCreatedAt = createdStdout.trim()
|
|
2254
|
+
}
|
|
2255
|
+
} catch {
|
|
2256
|
+
// Git 명령 실패 시 DB 값 사용
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
res.json({
|
|
2260
|
+
success: true,
|
|
2261
|
+
data: {
|
|
2262
|
+
change: {
|
|
2263
|
+
id: change.id,
|
|
2264
|
+
projectId: change.project_id,
|
|
2265
|
+
title: change.title,
|
|
2266
|
+
specPath: change.spec_path,
|
|
2267
|
+
status: change.status,
|
|
2268
|
+
currentStage,
|
|
2269
|
+
progress,
|
|
2270
|
+
createdAt: gitCreatedAt || new Date(change.created_at).toISOString(),
|
|
2271
|
+
updatedAt: gitUpdatedAt || new Date(change.updated_at).toISOString(),
|
|
2272
|
+
},
|
|
2273
|
+
stages,
|
|
2274
|
+
},
|
|
2275
|
+
})
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
console.error('Error getting flow change:', error)
|
|
2278
|
+
res.status(500).json({ success: false, error: 'Failed to get flow change' })
|
|
2279
|
+
}
|
|
2280
|
+
})
|
|
2281
|
+
|
|
2282
|
+
// POST /api/flow/sync - OpenSpec에서 Changes 동기화 (모든 프로젝트)
|
|
2283
|
+
app.post('/api/flow/sync', async (_req, res) => {
|
|
2284
|
+
try {
|
|
2285
|
+
await initTaskDb()
|
|
2286
|
+
const config = await loadConfig()
|
|
2287
|
+
|
|
2288
|
+
if (!config.projects.length) {
|
|
2289
|
+
return res.json({ success: true, data: { synced: 0, created: 0, updated: 0, projects: 0 } })
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const sqlite = getSqlite()
|
|
2293
|
+
let totalCreated = 0
|
|
2294
|
+
let totalUpdated = 0
|
|
2295
|
+
let projectsSynced = 0
|
|
2296
|
+
|
|
2297
|
+
// 모든 프로젝트 순회
|
|
2298
|
+
for (const project of config.projects) {
|
|
2299
|
+
const openspecDir = join(project.path, 'openspec', 'changes')
|
|
2300
|
+
let entries
|
|
2301
|
+
try {
|
|
2302
|
+
entries = await readdir(openspecDir, { withFileTypes: true })
|
|
2303
|
+
} catch {
|
|
2304
|
+
// openspec/changes 폴더가 없는 프로젝트는 스킵
|
|
2305
|
+
continue
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
projectsSynced++
|
|
2309
|
+
|
|
2310
|
+
for (const entry of entries) {
|
|
2311
|
+
if (!entry.isDirectory() || entry.name === 'archive') continue
|
|
2312
|
+
|
|
2313
|
+
const changeId = entry.name
|
|
2314
|
+
const changeDir = join(openspecDir, changeId)
|
|
2315
|
+
|
|
2316
|
+
// Read proposal.md for title
|
|
2317
|
+
let title = changeId
|
|
2318
|
+
const specPath = `openspec/changes/${changeId}/proposal.md`
|
|
2319
|
+
try {
|
|
2320
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
2321
|
+
const proposalContent = await readFile(proposalPath, 'utf-8')
|
|
2322
|
+
const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
|
|
2323
|
+
if (titleMatch) {
|
|
2324
|
+
title = titleMatch[1].trim()
|
|
2325
|
+
}
|
|
2326
|
+
} catch {
|
|
2327
|
+
// proposal.md not found
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// Check if change exists
|
|
2331
|
+
const existing = sqlite
|
|
2332
|
+
.prepare('SELECT id FROM changes WHERE id = ? AND project_id = ?')
|
|
2333
|
+
.get(changeId, project.id)
|
|
2334
|
+
const now = Date.now()
|
|
2335
|
+
|
|
2336
|
+
if (existing) {
|
|
2337
|
+
sqlite
|
|
2338
|
+
.prepare(
|
|
2339
|
+
`
|
|
2340
|
+
UPDATE changes SET title = ?, spec_path = ?, updated_at = ? WHERE id = ? AND project_id = ?
|
|
2341
|
+
`
|
|
2342
|
+
)
|
|
2343
|
+
.run(title, specPath, now, changeId, project.id)
|
|
2344
|
+
totalUpdated++
|
|
2345
|
+
} else {
|
|
2346
|
+
sqlite
|
|
2347
|
+
.prepare(
|
|
2348
|
+
`
|
|
2349
|
+
INSERT INTO changes (id, project_id, title, spec_path, status, current_stage, progress, created_at, updated_at)
|
|
2350
|
+
VALUES (?, ?, ?, ?, 'active', 'spec', 0, ?, ?)
|
|
2351
|
+
`
|
|
2352
|
+
)
|
|
2353
|
+
.run(changeId, project.id, title, specPath, now, now)
|
|
2354
|
+
totalCreated++
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// Sync tasks from tasks.md (3단계 계층 지원)
|
|
2358
|
+
// 전략: displayId 기반 매칭 (가장 안정적, 위치 기반)
|
|
2359
|
+
try {
|
|
2360
|
+
const tasksPath = join(changeDir, 'tasks.md')
|
|
2361
|
+
const tasksContent = await readFile(tasksPath, 'utf-8')
|
|
2362
|
+
const parsed = parseTasksFile(changeId, tasksContent)
|
|
2363
|
+
|
|
2364
|
+
// Extended group type with 3-level hierarchy
|
|
2365
|
+
interface ExtendedGroup {
|
|
2366
|
+
title: string
|
|
2367
|
+
tasks: Array<{
|
|
2368
|
+
title: string
|
|
2369
|
+
completed: boolean
|
|
2370
|
+
lineNumber: number
|
|
2371
|
+
displayId?: string
|
|
2372
|
+
}>
|
|
2373
|
+
majorOrder?: number
|
|
2374
|
+
majorTitle?: string
|
|
2375
|
+
subOrder?: number
|
|
2376
|
+
groupTitle?: string
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// 파일에서 파싱된 모든 displayId 수집 (삭제 감지용)
|
|
2380
|
+
const parsedDisplayIds = new Set<string>()
|
|
2381
|
+
|
|
2382
|
+
for (const group of parsed.groups as ExtendedGroup[]) {
|
|
2383
|
+
// 3단계 계층 정보 추출
|
|
2384
|
+
const majorOrder = group.majorOrder ?? 1
|
|
2385
|
+
const majorTitle = group.majorTitle ?? group.title
|
|
2386
|
+
const subOrder = group.subOrder ?? 1
|
|
2387
|
+
const groupTitle = group.groupTitle ?? group.title // ### 1.1 Subsection Title
|
|
2388
|
+
|
|
2389
|
+
for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
|
|
2390
|
+
const task = group.tasks[taskIdx]
|
|
2391
|
+
const taskOrder = taskIdx + 1
|
|
2392
|
+
const displayId = task.displayId || null
|
|
2393
|
+
|
|
2394
|
+
if (displayId) {
|
|
2395
|
+
parsedDisplayIds.add(displayId)
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// 매칭 전략 (우선순위 순):
|
|
2399
|
+
// 1. displayId로 매칭 (가장 안정적 - 파서가 순서 기반으로 생성)
|
|
2400
|
+
// 2. change_id + title로 매칭 (displayId가 없는 레거시 데이터용)
|
|
2401
|
+
let existingTask: { id: number } | undefined
|
|
2402
|
+
|
|
2403
|
+
if (displayId) {
|
|
2404
|
+
existingTask = sqlite
|
|
2405
|
+
.prepare(
|
|
2406
|
+
`
|
|
2407
|
+
SELECT id FROM tasks WHERE change_id = ? AND display_id = ?
|
|
2408
|
+
`
|
|
2409
|
+
)
|
|
2410
|
+
.get(changeId, displayId) as { id: number } | undefined
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// displayId로 못 찾으면 title로 시도 (레거시 호환)
|
|
2414
|
+
if (!existingTask) {
|
|
2415
|
+
existingTask = sqlite
|
|
2416
|
+
.prepare(
|
|
2417
|
+
`
|
|
2418
|
+
SELECT id FROM tasks WHERE change_id = ? AND title = ?
|
|
2419
|
+
`
|
|
2420
|
+
)
|
|
2421
|
+
.get(changeId, task.title) as { id: number } | undefined
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
if (existingTask) {
|
|
2425
|
+
// 기존 태스크 업데이트
|
|
2426
|
+
const newStatus = task.completed ? 'done' : 'todo'
|
|
2427
|
+
sqlite
|
|
2428
|
+
.prepare(
|
|
2429
|
+
`
|
|
2430
|
+
UPDATE tasks
|
|
2431
|
+
SET title = ?,
|
|
2432
|
+
status = ?,
|
|
2433
|
+
group_title = ?,
|
|
2434
|
+
group_order = ?,
|
|
2435
|
+
task_order = ?,
|
|
2436
|
+
major_title = ?,
|
|
2437
|
+
sub_order = ?,
|
|
2438
|
+
display_id = ?,
|
|
2439
|
+
project_id = ?,
|
|
2440
|
+
updated_at = ?
|
|
2441
|
+
WHERE id = ?
|
|
2442
|
+
`
|
|
2443
|
+
)
|
|
2444
|
+
.run(
|
|
2445
|
+
task.title,
|
|
2446
|
+
newStatus,
|
|
2447
|
+
groupTitle,
|
|
2448
|
+
majorOrder,
|
|
2449
|
+
taskOrder,
|
|
2450
|
+
majorTitle,
|
|
2451
|
+
subOrder,
|
|
2452
|
+
displayId,
|
|
2453
|
+
project.id,
|
|
2454
|
+
now,
|
|
2455
|
+
existingTask.id
|
|
2456
|
+
)
|
|
2457
|
+
} else {
|
|
2458
|
+
// 새 태스크 생성
|
|
2459
|
+
// TAG-014: Changed from 'task_openspec' to 'task_moai' sequence
|
|
2460
|
+
sqlite
|
|
2461
|
+
.prepare(`UPDATE sequences SET value = value + 1 WHERE name = 'task_moai'`)
|
|
2462
|
+
.run()
|
|
2463
|
+
const seqResult = sqlite
|
|
2464
|
+
.prepare(`SELECT value FROM sequences WHERE name = 'task_moai'`)
|
|
2465
|
+
.get() as { value: number }
|
|
2466
|
+
const newId = seqResult.value
|
|
2467
|
+
|
|
2468
|
+
sqlite
|
|
2469
|
+
.prepare(
|
|
2470
|
+
`
|
|
2471
|
+
INSERT INTO tasks (
|
|
2472
|
+
id, project_id, change_id, stage, title, status, priority, "order",
|
|
2473
|
+
group_title, group_order, task_order, major_title, sub_order,
|
|
2474
|
+
display_id, origin, created_at, updated_at
|
|
2475
|
+
)
|
|
2476
|
+
VALUES (?, ?, ?, 'task', ?, ?, 'medium', ?, ?, ?, ?, ?, ?, ?, 'moai', ?, ?)
|
|
2477
|
+
`
|
|
2478
|
+
)
|
|
2479
|
+
.run(
|
|
2480
|
+
newId,
|
|
2481
|
+
project.id,
|
|
2482
|
+
changeId,
|
|
2483
|
+
task.title,
|
|
2484
|
+
task.completed ? 'done' : 'todo',
|
|
2485
|
+
task.lineNumber,
|
|
2486
|
+
groupTitle,
|
|
2487
|
+
majorOrder,
|
|
2488
|
+
taskOrder,
|
|
2489
|
+
majorTitle,
|
|
2490
|
+
subOrder,
|
|
2491
|
+
displayId,
|
|
2492
|
+
now,
|
|
2493
|
+
now
|
|
2494
|
+
)
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
// 파일에서 삭제된 태스크 정리 (displayId가 있는 태스크 중 파일에 없는 것)
|
|
2500
|
+
// 삭제 대신 archived 상태로 변경 (데이터 보존)
|
|
2501
|
+
if (parsedDisplayIds.size > 0) {
|
|
2502
|
+
const dbTasks = sqlite
|
|
2503
|
+
.prepare(
|
|
2504
|
+
`
|
|
2505
|
+
SELECT id, display_id FROM tasks
|
|
2506
|
+
WHERE change_id = ? AND display_id IS NOT NULL AND status != 'archived'
|
|
2507
|
+
`
|
|
2508
|
+
)
|
|
2509
|
+
.all(changeId) as Array<{ id: number; display_id: string }>
|
|
2510
|
+
|
|
2511
|
+
for (const dbTask of dbTasks) {
|
|
2512
|
+
if (!parsedDisplayIds.has(dbTask.display_id)) {
|
|
2513
|
+
// 파일에서 삭제된 태스크 - archived로 표시
|
|
2514
|
+
sqlite
|
|
2515
|
+
.prepare(
|
|
2516
|
+
`
|
|
2517
|
+
UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?
|
|
2518
|
+
`
|
|
2519
|
+
)
|
|
2520
|
+
.run(now, now, dbTask.id)
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
} catch {
|
|
2525
|
+
// tasks.md not found or parse error
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
res.json({
|
|
2531
|
+
success: true,
|
|
2532
|
+
data: {
|
|
2533
|
+
synced: totalCreated + totalUpdated,
|
|
2534
|
+
created: totalCreated,
|
|
2535
|
+
updated: totalUpdated,
|
|
2536
|
+
projects: projectsSynced,
|
|
2537
|
+
},
|
|
2538
|
+
})
|
|
2539
|
+
} catch (error) {
|
|
2540
|
+
console.error('Error syncing flow changes:', error)
|
|
2541
|
+
res.status(500).json({ success: false, error: 'Failed to sync flow changes' })
|
|
2542
|
+
}
|
|
2543
|
+
})
|
|
2544
|
+
|
|
2545
|
+
// GET /api/flow/tasks - Flow Tasks 목록 (필터링)
|
|
2546
|
+
app.get('/api/flow/tasks', async (req, res) => {
|
|
2547
|
+
try {
|
|
2548
|
+
await initTaskDb()
|
|
2549
|
+
const { changeId, stage, status, standalone, includeArchived } = req.query
|
|
2550
|
+
|
|
2551
|
+
const project = await getActiveProject()
|
|
2552
|
+
if (!project) {
|
|
2553
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// 원격 프로젝트: SSH로 tasks.md 실시간 조회 (On-Demand)
|
|
2557
|
+
if (project.remote && changeId) {
|
|
2558
|
+
try {
|
|
2559
|
+
const remotePlugin = await import('@zyflow/remote-plugin')
|
|
2560
|
+
const server = await remotePlugin.getRemoteServerById(project.remote.serverId)
|
|
2561
|
+
if (server) {
|
|
2562
|
+
const tasksPath = `${project.path}/openspec/changes/${changeId}/tasks.md`
|
|
2563
|
+
const tasksContent = await remotePlugin.readRemoteFile(server, tasksPath)
|
|
2564
|
+
const parsed = parseTasksFile(changeId as string, tasksContent)
|
|
2565
|
+
|
|
2566
|
+
// 파싱된 Tasks를 프론트엔드 형식으로 변환
|
|
2567
|
+
const formatted: Array<{
|
|
2568
|
+
id: number
|
|
2569
|
+
changeId: string | null
|
|
2570
|
+
stage: string
|
|
2571
|
+
title: string
|
|
2572
|
+
description: string | null
|
|
2573
|
+
status: string
|
|
2574
|
+
priority: string
|
|
2575
|
+
tags: string[]
|
|
2576
|
+
assignee: string | null
|
|
2577
|
+
order: number
|
|
2578
|
+
displayId: string | null
|
|
2579
|
+
createdAt: string
|
|
2580
|
+
updatedAt: string
|
|
2581
|
+
archivedAt: string | null
|
|
2582
|
+
groupTitle?: string
|
|
2583
|
+
majorTitle?: string
|
|
2584
|
+
}> = []
|
|
2585
|
+
|
|
2586
|
+
let taskId = 1
|
|
2587
|
+
for (const group of parsed.groups) {
|
|
2588
|
+
for (const task of group.tasks) {
|
|
2589
|
+
formatted.push({
|
|
2590
|
+
id: taskId++,
|
|
2591
|
+
changeId: changeId as string,
|
|
2592
|
+
stage: 'task',
|
|
2593
|
+
title: task.title,
|
|
2594
|
+
description: null,
|
|
2595
|
+
status: task.completed ? 'done' : 'todo',
|
|
2596
|
+
priority: 'medium',
|
|
2597
|
+
tags: [],
|
|
2598
|
+
assignee: null,
|
|
2599
|
+
order: task.lineNumber,
|
|
2600
|
+
displayId: task.displayId || null,
|
|
2601
|
+
createdAt: new Date().toISOString(),
|
|
2602
|
+
updatedAt: new Date().toISOString(),
|
|
2603
|
+
archivedAt: null,
|
|
2604
|
+
groupTitle: group.title,
|
|
2605
|
+
majorTitle: (group as { majorTitle?: string }).majorTitle || group.title,
|
|
2606
|
+
})
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
return res.json({ success: true, data: { tasks: formatted } })
|
|
2611
|
+
}
|
|
2612
|
+
} catch (e) {
|
|
2613
|
+
console.warn('[Remote Tasks] Failed to fetch via SSH, falling back to DB:', e)
|
|
2614
|
+
// SSH 실패 시 DB에서 조회 (fallback)
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
// 로컬 프로젝트 또는 원격 프로젝트 fallback: DB에서 조회
|
|
2619
|
+
const sqlite = getSqlite()
|
|
2620
|
+
let sql = 'SELECT * FROM tasks WHERE project_id = ?'
|
|
2621
|
+
const params: unknown[] = [project.id]
|
|
2622
|
+
|
|
2623
|
+
if (standalone === 'true') {
|
|
2624
|
+
sql += ' AND change_id IS NULL'
|
|
2625
|
+
} else if (changeId) {
|
|
2626
|
+
sql += ' AND change_id = ?'
|
|
2627
|
+
params.push(changeId)
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
if (stage) {
|
|
2631
|
+
sql += ' AND stage = ?'
|
|
2632
|
+
params.push(stage)
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
if (status) {
|
|
2636
|
+
sql += ' AND status = ?'
|
|
2637
|
+
params.push(status)
|
|
2638
|
+
} else if (includeArchived !== 'true') {
|
|
2639
|
+
sql += " AND status != 'archived'"
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
sql += ' ORDER BY "order", created_at'
|
|
2643
|
+
|
|
2644
|
+
const tasks = sqlite.prepare(sql).all(...params) as Array<{
|
|
2645
|
+
id: number
|
|
2646
|
+
change_id: string | null
|
|
2647
|
+
stage: Stage
|
|
2648
|
+
title: string
|
|
2649
|
+
description: string | null
|
|
2650
|
+
status: string
|
|
2651
|
+
priority: string
|
|
2652
|
+
tags: string | null
|
|
2653
|
+
assignee: string | null
|
|
2654
|
+
order: number
|
|
2655
|
+
display_id: string | null
|
|
2656
|
+
created_at: number
|
|
2657
|
+
updated_at: number
|
|
2658
|
+
archived_at: number | null
|
|
2659
|
+
}>
|
|
2660
|
+
|
|
2661
|
+
const formatted = tasks.map((t) => ({
|
|
2662
|
+
id: t.id,
|
|
2663
|
+
changeId: t.change_id,
|
|
2664
|
+
stage: t.stage,
|
|
2665
|
+
title: t.title,
|
|
2666
|
+
description: t.description,
|
|
2667
|
+
status: t.status,
|
|
2668
|
+
priority: t.priority,
|
|
2669
|
+
tags: t.tags ? JSON.parse(t.tags) : [],
|
|
2670
|
+
assignee: t.assignee,
|
|
2671
|
+
order: t.order,
|
|
2672
|
+
displayId: t.display_id,
|
|
2673
|
+
createdAt: new Date(t.created_at).toISOString(),
|
|
2674
|
+
updatedAt: new Date(t.updated_at).toISOString(),
|
|
2675
|
+
archivedAt: t.archived_at ? new Date(t.archived_at).toISOString() : null,
|
|
2676
|
+
}))
|
|
2677
|
+
|
|
2678
|
+
res.json({ success: true, data: { tasks: formatted } })
|
|
2679
|
+
} catch (error) {
|
|
2680
|
+
console.error('Error listing flow tasks:', error)
|
|
2681
|
+
res.status(500).json({ success: false, error: 'Failed to list flow tasks' })
|
|
2682
|
+
}
|
|
2683
|
+
})
|
|
2684
|
+
|
|
2685
|
+
// POST /api/flow/tasks - Flow Task 생성
|
|
2686
|
+
app.post('/api/flow/tasks', async (req, res) => {
|
|
2687
|
+
try {
|
|
2688
|
+
await initTaskDb()
|
|
2689
|
+
const project = await getActiveProject()
|
|
2690
|
+
if (!project) {
|
|
2691
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2692
|
+
}
|
|
2693
|
+
const { changeId, stage, title, description, priority } = req.body
|
|
2694
|
+
|
|
2695
|
+
if (!title) {
|
|
2696
|
+
return res.status(400).json({ success: false, error: 'Title is required' })
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
const sqlite = getSqlite()
|
|
2700
|
+
const now = Date.now()
|
|
2701
|
+
|
|
2702
|
+
const result = sqlite
|
|
2703
|
+
.prepare(
|
|
2704
|
+
`
|
|
2705
|
+
INSERT INTO tasks (project_id, change_id, stage, title, description, status, priority, "order", created_at, updated_at)
|
|
2706
|
+
VALUES (?, ?, ?, ?, ?, 'todo', ?, 0, ?, ?)
|
|
2707
|
+
`
|
|
2708
|
+
)
|
|
2709
|
+
.run(
|
|
2710
|
+
project.id,
|
|
2711
|
+
changeId || null,
|
|
2712
|
+
stage || 'task',
|
|
2713
|
+
title,
|
|
2714
|
+
description || null,
|
|
2715
|
+
priority || 'medium',
|
|
2716
|
+
now,
|
|
2717
|
+
now
|
|
2718
|
+
)
|
|
2719
|
+
|
|
2720
|
+
const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid)
|
|
2721
|
+
|
|
2722
|
+
// WebSocket으로 태스크 생성 알림
|
|
2723
|
+
emit('task:created', { task })
|
|
2724
|
+
|
|
2725
|
+
res.json({ success: true, data: { task } })
|
|
2726
|
+
} catch (error) {
|
|
2727
|
+
console.error('Error creating flow task:', error)
|
|
2728
|
+
res.status(500).json({ success: false, error: 'Failed to create flow task' })
|
|
2729
|
+
}
|
|
2730
|
+
})
|
|
2731
|
+
|
|
2732
|
+
// GET /api/flow/changes/:id/proposal - Change의 proposal.md 내용
|
|
2733
|
+
app.get('/api/flow/changes/:id/proposal', async (req, res) => {
|
|
2734
|
+
try {
|
|
2735
|
+
const paths = await getProjectPaths()
|
|
2736
|
+
if (!paths) {
|
|
2737
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
const changeId = req.params.id
|
|
2741
|
+
const proposalPath = join(paths.openspecDir, changeId, 'proposal.md')
|
|
2742
|
+
|
|
2743
|
+
try {
|
|
2744
|
+
const content = await readFile(proposalPath, 'utf-8')
|
|
2745
|
+
res.json({ success: true, data: { changeId, content } })
|
|
2746
|
+
} catch {
|
|
2747
|
+
res.json({ success: true, data: { changeId, content: null } })
|
|
2748
|
+
}
|
|
2749
|
+
} catch (error) {
|
|
2750
|
+
console.error('Error reading proposal:', error)
|
|
2751
|
+
res.status(500).json({ success: false, error: 'Failed to read proposal' })
|
|
2752
|
+
}
|
|
2753
|
+
})
|
|
2754
|
+
|
|
2755
|
+
// GET /api/flow/changes/:id/design - Change의 design.md 내용
|
|
2756
|
+
app.get('/api/flow/changes/:id/design', async (req, res) => {
|
|
2757
|
+
try {
|
|
2758
|
+
const paths = await getProjectPaths()
|
|
2759
|
+
if (!paths) {
|
|
2760
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
const changeId = req.params.id
|
|
2764
|
+
const designPath = join(paths.openspecDir, changeId, 'design.md')
|
|
2765
|
+
|
|
2766
|
+
try {
|
|
2767
|
+
const content = await readFile(designPath, 'utf-8')
|
|
2768
|
+
res.json({ success: true, data: { changeId, content } })
|
|
2769
|
+
} catch {
|
|
2770
|
+
res.json({ success: true, data: { changeId, content: null } })
|
|
2771
|
+
}
|
|
2772
|
+
} catch (error) {
|
|
2773
|
+
console.error('Error reading design:', error)
|
|
2774
|
+
res.status(500).json({ success: false, error: 'Failed to read design' })
|
|
2775
|
+
}
|
|
2776
|
+
})
|
|
2777
|
+
|
|
2778
|
+
// GET /api/flow/changes/:id/spec - Change의 첫 번째 spec.md 내용
|
|
2779
|
+
app.get('/api/flow/changes/:id/spec', async (req, res) => {
|
|
2780
|
+
try {
|
|
2781
|
+
const paths = await getProjectPaths()
|
|
2782
|
+
if (!paths) {
|
|
2783
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
const changeId = req.params.id
|
|
2787
|
+
const specsDir = join(paths.openspecDir, changeId, 'specs')
|
|
2788
|
+
|
|
2789
|
+
try {
|
|
2790
|
+
// specs 디렉토리에서 첫 번째 spec 폴더 찾기
|
|
2791
|
+
const specFolders = await readdir(specsDir)
|
|
2792
|
+
if (specFolders.length === 0) {
|
|
2793
|
+
return res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// 첫 번째 spec 폴더의 spec.md 읽기
|
|
2797
|
+
const firstSpecId = specFolders[0]
|
|
2798
|
+
const specPath = join(specsDir, firstSpecId, 'spec.md')
|
|
2799
|
+
const content = await readFile(specPath, 'utf-8')
|
|
2800
|
+
res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
|
|
2801
|
+
} catch {
|
|
2802
|
+
res.json({ success: true, data: { changeId, content: null, specId: null } })
|
|
2803
|
+
}
|
|
2804
|
+
} catch (error) {
|
|
2805
|
+
console.error('Error reading spec:', error)
|
|
2806
|
+
res.status(500).json({ success: false, error: 'Failed to read spec' })
|
|
2807
|
+
}
|
|
2808
|
+
})
|
|
2809
|
+
|
|
2810
|
+
// GET /api/flow/changes/:changeId/specs/:specId - 특정 spec.md 내용
|
|
2811
|
+
app.get('/api/flow/changes/:changeId/specs/:specId', async (req, res) => {
|
|
2812
|
+
try {
|
|
2813
|
+
const paths = await getProjectPaths()
|
|
2814
|
+
if (!paths) {
|
|
2815
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
const { changeId, specId } = req.params
|
|
2819
|
+
|
|
2820
|
+
// 1. Change 내 specs 디렉토리 확인
|
|
2821
|
+
const changeSpecPath = join(paths.openspecDir, changeId, 'specs', specId, 'spec.md')
|
|
2822
|
+
try {
|
|
2823
|
+
const content = await readFile(changeSpecPath, 'utf-8')
|
|
2824
|
+
return res.json({ success: true, data: { specId, content, location: 'change' } })
|
|
2825
|
+
} catch {
|
|
2826
|
+
// Change 내에 없으면 archived specs에서 찾기
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
// 2. Archived specs 디렉토리에서 확인
|
|
2830
|
+
const archivedSpecPath = join(paths.specsDir, specId, 'spec.md')
|
|
2831
|
+
try {
|
|
2832
|
+
const content = await readFile(archivedSpecPath, 'utf-8')
|
|
2833
|
+
return res.json({ success: true, data: { specId, content, location: 'archived' } })
|
|
2834
|
+
} catch {
|
|
2835
|
+
return res.json({ success: true, data: { specId, content: null, location: null } })
|
|
2836
|
+
}
|
|
2837
|
+
} catch (error) {
|
|
2838
|
+
console.error('Error reading change spec:', error)
|
|
2839
|
+
res.status(500).json({ success: false, error: 'Failed to read spec' })
|
|
2840
|
+
}
|
|
2841
|
+
})
|
|
2842
|
+
|
|
2843
|
+
// PATCH /api/flow/tasks/:id - Flow Task 수정
|
|
2844
|
+
app.patch('/api/flow/tasks/:id', async (req, res) => {
|
|
2845
|
+
try {
|
|
2846
|
+
await initTaskDb()
|
|
2847
|
+
const { changeId, stage, title, description, status, priority, order } = req.body
|
|
2848
|
+
|
|
2849
|
+
const sqlite = getSqlite()
|
|
2850
|
+
const existing = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
|
|
2851
|
+
|
|
2852
|
+
if (!existing) {
|
|
2853
|
+
return res.status(404).json({ success: false, error: 'Task not found' })
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
const updates: string[] = []
|
|
2857
|
+
const params: unknown[] = []
|
|
2858
|
+
|
|
2859
|
+
if (changeId !== undefined) {
|
|
2860
|
+
updates.push('change_id = ?')
|
|
2861
|
+
params.push(changeId)
|
|
2862
|
+
}
|
|
2863
|
+
if (stage !== undefined) {
|
|
2864
|
+
updates.push('stage = ?')
|
|
2865
|
+
params.push(stage)
|
|
2866
|
+
}
|
|
2867
|
+
if (title !== undefined) {
|
|
2868
|
+
updates.push('title = ?')
|
|
2869
|
+
params.push(title)
|
|
2870
|
+
}
|
|
2871
|
+
if (description !== undefined) {
|
|
2872
|
+
updates.push('description = ?')
|
|
2873
|
+
params.push(description)
|
|
2874
|
+
}
|
|
2875
|
+
if (status !== undefined) {
|
|
2876
|
+
updates.push('status = ?')
|
|
2877
|
+
params.push(status)
|
|
2878
|
+
}
|
|
2879
|
+
if (priority !== undefined) {
|
|
2880
|
+
updates.push('priority = ?')
|
|
2881
|
+
params.push(priority)
|
|
2882
|
+
}
|
|
2883
|
+
if (order !== undefined) {
|
|
2884
|
+
updates.push('"order" = ?')
|
|
2885
|
+
params.push(order)
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
updates.push('updated_at = ?')
|
|
2889
|
+
params.push(Date.now())
|
|
2890
|
+
params.push(req.params.id)
|
|
2891
|
+
|
|
2892
|
+
sqlite.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params)
|
|
2893
|
+
|
|
2894
|
+
const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
|
|
2895
|
+
|
|
2896
|
+
// WebSocket으로 태스크 업데이트 알림
|
|
2897
|
+
emit('task:updated', { task })
|
|
2898
|
+
|
|
2899
|
+
res.json({ success: true, data: { task } })
|
|
2900
|
+
} catch (error) {
|
|
2901
|
+
console.error('Error updating flow task:', error)
|
|
2902
|
+
res.status(500).json({ success: false, error: 'Failed to update flow task' })
|
|
2903
|
+
}
|
|
2904
|
+
})
|
|
2905
|
+
|
|
2906
|
+
// ==================== OPENSPEC ARCHIVE ====================
|
|
2907
|
+
|
|
2908
|
+
// ==================== PYTHON AGENTS API PROXY ====================
|
|
2909
|
+
|
|
2910
|
+
// Health check for Python agents server
|
|
2911
|
+
app.get('/api/agents/health', async (_req, res) => {
|
|
2912
|
+
try {
|
|
2913
|
+
const response = await fetch('http://localhost:3002/health')
|
|
2914
|
+
if (!response.ok) {
|
|
2915
|
+
return res.status(503).json({
|
|
2916
|
+
success: false,
|
|
2917
|
+
error: 'Python agents server unavailable',
|
|
2918
|
+
pythonStatus: 'offline',
|
|
2919
|
+
})
|
|
2920
|
+
}
|
|
2921
|
+
const data = await response.json()
|
|
2922
|
+
res.json({ success: true, data: { ...data, pythonStatus: 'online' } })
|
|
2923
|
+
} catch {
|
|
2924
|
+
res.json({
|
|
2925
|
+
success: true,
|
|
2926
|
+
data: {
|
|
2927
|
+
status: 'unavailable',
|
|
2928
|
+
pythonStatus: 'offline',
|
|
2929
|
+
message: 'Python agents server is not running. Start with: npm run py:server',
|
|
2930
|
+
},
|
|
2931
|
+
})
|
|
2932
|
+
}
|
|
2933
|
+
})
|
|
2934
|
+
|
|
2935
|
+
// Proxy all /api/agents/* requests to Python server
|
|
2936
|
+
app.use('/api/agents', async (req, res) => {
|
|
2937
|
+
try {
|
|
2938
|
+
// 0. Handle session list request (GET /sessions)
|
|
2939
|
+
const isSessionListRequest = req.path === '/sessions' && req.method === 'GET'
|
|
2940
|
+
if (isSessionListRequest) {
|
|
2941
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
2942
|
+
try {
|
|
2943
|
+
const pm = getProcessManager()
|
|
2944
|
+
const sessions = pm.getAllSessions()
|
|
2945
|
+
return res.json(
|
|
2946
|
+
sessions.map((session) => ({
|
|
2947
|
+
session_id: session.id,
|
|
2948
|
+
change_id: session.changeId,
|
|
2949
|
+
status: session.status,
|
|
2950
|
+
created_at: session.startedAt,
|
|
2951
|
+
updated_at: session.endedAt || session.startedAt,
|
|
2952
|
+
project_path: session.projectPath,
|
|
2953
|
+
current_task: null,
|
|
2954
|
+
completed_tasks: 0,
|
|
2955
|
+
total_tasks: 0,
|
|
2956
|
+
error: session.error || null,
|
|
2957
|
+
conversation_history: session.conversationHistory || [],
|
|
2958
|
+
}))
|
|
2959
|
+
)
|
|
2960
|
+
} catch {
|
|
2961
|
+
// ProcessManager not initialized, return empty array
|
|
2962
|
+
return res.json([])
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
// 0.5. Handle session delete request (DELETE /sessions/:id)
|
|
2967
|
+
const isSessionDeleteRequest = req.path.match(/^\/sessions\/[^/]+$/) && req.method === 'DELETE'
|
|
2968
|
+
if (isSessionDeleteRequest) {
|
|
2969
|
+
const sessionId = req.path.split('/')[2]
|
|
2970
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
2971
|
+
try {
|
|
2972
|
+
const pm = getProcessManager()
|
|
2973
|
+
const deleted = pm.deleteSession(sessionId)
|
|
2974
|
+
if (deleted) {
|
|
2975
|
+
return res.json({ success: true })
|
|
2976
|
+
}
|
|
2977
|
+
return res.status(404).json({ success: false, error: 'Session not found' })
|
|
2978
|
+
} catch {
|
|
2979
|
+
return res.status(404).json({ success: false, error: 'Session not found' })
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
// 1. Check for CLI mode request or Stream request for CLI session
|
|
2984
|
+
const isExecuteRequest = req.path === '/execute' && req.method === 'POST'
|
|
2985
|
+
const isStreamRequest = req.path.match(/\/sessions\/[^/]+\/stream/) && req.method === 'GET'
|
|
2986
|
+
|
|
2987
|
+
// Check if it's a CLI session stream or input request
|
|
2988
|
+
// session input path example: /sessions/UUID/input
|
|
2989
|
+
const isInputRequest = req.path.match(/\/sessions\/[^/]+\/input/) && req.method === 'POST'
|
|
2990
|
+
// session logs path example: /sessions/UUID/logs
|
|
2991
|
+
const isLogsRequest = req.path.match(/\/sessions\/[^/]+\/logs/) && req.method === 'GET'
|
|
2992
|
+
// session status path example: /sessions/UUID (exact match, no trailing path)
|
|
2993
|
+
const isSessionStatusRequest = req.path.match(/^\/sessions\/[^/]+$/) && req.method === 'GET'
|
|
2994
|
+
|
|
2995
|
+
let isCliSession = false
|
|
2996
|
+
let targetSessionId: string | undefined
|
|
2997
|
+
|
|
2998
|
+
// Check if any session-related request is for a CLI session
|
|
2999
|
+
if (isStreamRequest || isInputRequest || isLogsRequest || isSessionStatusRequest) {
|
|
3000
|
+
targetSessionId = req.path.split('/')[2]
|
|
3001
|
+
|
|
3002
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
3003
|
+
try {
|
|
3004
|
+
const pm = getProcessManager()
|
|
3005
|
+
const session = pm.getSession(targetSessionId)
|
|
3006
|
+
|
|
3007
|
+
if (session) {
|
|
3008
|
+
isCliSession = true
|
|
3009
|
+
}
|
|
3010
|
+
} catch {
|
|
3011
|
+
// ProcessManager not initialized, fall through to Python server
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// Handle CLI Session Status request
|
|
3016
|
+
if (isSessionStatusRequest && isCliSession && targetSessionId) {
|
|
3017
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
3018
|
+
const pm = getProcessManager()
|
|
3019
|
+
const session = pm.getSession(targetSessionId)
|
|
3020
|
+
|
|
3021
|
+
if (session) {
|
|
3022
|
+
return res.json({
|
|
3023
|
+
session_id: session.id,
|
|
3024
|
+
change_id: session.changeId,
|
|
3025
|
+
status: session.status,
|
|
3026
|
+
created_at: session.startedAt,
|
|
3027
|
+
updated_at: session.endedAt || session.startedAt,
|
|
3028
|
+
project_path: session.projectPath,
|
|
3029
|
+
current_task: null,
|
|
3030
|
+
completed_tasks: 0,
|
|
3031
|
+
total_tasks: 0,
|
|
3032
|
+
error: session.error || null,
|
|
3033
|
+
// Include conversation history for message persistence
|
|
3034
|
+
conversation_history: session.conversationHistory || [],
|
|
3035
|
+
})
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
// 2. Handle CLI Execution
|
|
3040
|
+
// 2. Handle CLI Execution
|
|
3041
|
+
if (isExecuteRequest && req.body.use_cli === true) {
|
|
3042
|
+
console.log('[Proxy] Routing to CLI Adapter (Execute)')
|
|
3043
|
+
const { initProcessManager, getProcessManager } =
|
|
3044
|
+
await import('./cli-adapter/process-manager.js')
|
|
3045
|
+
const projectPath = req.body.project_path || process.cwd()
|
|
3046
|
+
|
|
3047
|
+
let processManager
|
|
3048
|
+
try {
|
|
3049
|
+
processManager = getProcessManager(projectPath)
|
|
3050
|
+
} catch {
|
|
3051
|
+
processManager = initProcessManager(projectPath)
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
const result = await processManager.start({
|
|
3055
|
+
profileId: 'claude', // Default to claude
|
|
3056
|
+
changeId: req.body.change_id,
|
|
3057
|
+
projectPath,
|
|
3058
|
+
initialPrompt: req.body.initial_prompt,
|
|
3059
|
+
})
|
|
3060
|
+
|
|
3061
|
+
if (!result.success) {
|
|
3062
|
+
return res.status(500).json({ success: false, error: result.error })
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
return res.json({
|
|
3066
|
+
session_id: result.sessionId,
|
|
3067
|
+
status: 'running',
|
|
3068
|
+
message: 'CLI Session started via Adapter',
|
|
3069
|
+
change_id: req.body.change_id || 'unknown',
|
|
3070
|
+
created_at: new Date().toISOString(),
|
|
3071
|
+
updated_at: new Date().toISOString(),
|
|
3072
|
+
project_path: projectPath,
|
|
3073
|
+
})
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
// 3. Handle CLI Input
|
|
3077
|
+
if (isInputRequest && isCliSession && targetSessionId) {
|
|
3078
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
3079
|
+
const processManager = getProcessManager()
|
|
3080
|
+
|
|
3081
|
+
const result = await processManager.sendInput(targetSessionId, req.body.input || '')
|
|
3082
|
+
if (!result.success) {
|
|
3083
|
+
return res.status(500).json({ success: false, error: result.error })
|
|
3084
|
+
}
|
|
3085
|
+
return res.json({ success: true })
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// 4. Handle CLI Stream
|
|
3089
|
+
if (isCliSession) {
|
|
3090
|
+
const sessionId = targetSessionId || req.path.split('/')[2]
|
|
3091
|
+
const { getProcessManager } = await import('./cli-adapter/process-manager.js')
|
|
3092
|
+
const processManager = getProcessManager()
|
|
3093
|
+
|
|
3094
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
3095
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
3096
|
+
res.setHeader('Connection', 'keep-alive')
|
|
3097
|
+
|
|
3098
|
+
const sendEvent = (type: string, data: Record<string, unknown>) => {
|
|
3099
|
+
if (res.writableEnded || !res.writable) return
|
|
3100
|
+
try {
|
|
3101
|
+
res.write(`event: message\ndata: ${JSON.stringify({ type, ...data })}\n\n`)
|
|
3102
|
+
} catch (e) {
|
|
3103
|
+
console.error('[SSE] Error sending event:', e)
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// Convert CLI output to Agent events
|
|
3108
|
+
const onOutput = (output: { sessionId: string; content: string; timestamp: string }) => {
|
|
3109
|
+
if (output.sessionId === sessionId) {
|
|
3110
|
+
// Map stdout to agent_response or task_complete based on content?
|
|
3111
|
+
// For now just stream raw output as agent_response
|
|
3112
|
+
sendEvent('agent_response', {
|
|
3113
|
+
content: output.content,
|
|
3114
|
+
timestamp: output.timestamp,
|
|
3115
|
+
})
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
const onEnd = (session: { id: string }) => {
|
|
3120
|
+
if (session.id === sessionId) {
|
|
3121
|
+
sendEvent('session_end', { timestamp: new Date().toISOString() })
|
|
3122
|
+
if (!res.writableEnded) {
|
|
3123
|
+
res.end()
|
|
3124
|
+
}
|
|
3125
|
+
processManager.off('output', onOutput)
|
|
3126
|
+
processManager.off('session:end', onEnd)
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
// 만약 이미 종료된 세션이라면?
|
|
3131
|
+
const session = processManager.getSession(sessionId)
|
|
3132
|
+
if (session && (session.status === 'completed' || session.status === 'failed')) {
|
|
3133
|
+
// 이미 종료된 세션의 로그를 한꺼번에 보내주거나 종료 처리
|
|
3134
|
+
onEnd(session)
|
|
3135
|
+
return
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
processManager.on('output', onOutput)
|
|
3139
|
+
processManager.on('session:end', onEnd)
|
|
3140
|
+
|
|
3141
|
+
req.on('close', () => {
|
|
3142
|
+
processManager.off('output', onOutput)
|
|
3143
|
+
processManager.off('session:end', onEnd)
|
|
3144
|
+
})
|
|
3145
|
+
|
|
3146
|
+
return
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
// 4. Fallback to Python Server (Normal Flow)
|
|
3150
|
+
const targetUrl = `http://localhost:3002${req.originalUrl}`
|
|
3151
|
+
|
|
3152
|
+
const fetchOptions: RequestInit = {
|
|
3153
|
+
method: req.method,
|
|
3154
|
+
headers: {
|
|
3155
|
+
'Content-Type': 'application/json',
|
|
3156
|
+
},
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
3160
|
+
fetchOptions.body = JSON.stringify(req.body)
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
const response = await fetch(targetUrl, fetchOptions)
|
|
3164
|
+
|
|
3165
|
+
// Handle SSE streams
|
|
3166
|
+
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
|
3167
|
+
res.setHeader('Content-Type', 'text/event-stream')
|
|
3168
|
+
res.setHeader('Cache-Control', 'no-cache')
|
|
3169
|
+
res.setHeader('Connection', 'keep-alive')
|
|
3170
|
+
|
|
3171
|
+
const reader = response.body?.getReader()
|
|
3172
|
+
if (reader) {
|
|
3173
|
+
const decoder = new TextDecoder()
|
|
3174
|
+
while (true) {
|
|
3175
|
+
const { done, value } = await reader.read()
|
|
3176
|
+
if (done) break
|
|
3177
|
+
res.write(decoder.decode(value))
|
|
3178
|
+
}
|
|
3179
|
+
res.end()
|
|
3180
|
+
}
|
|
3181
|
+
return
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
const data = await response.json()
|
|
3185
|
+
res.status(response.status).json(data)
|
|
3186
|
+
} catch (error) {
|
|
3187
|
+
console.error('Error proxying to Python agents server:', error)
|
|
3188
|
+
|
|
3189
|
+
// Auto-fallback to CLI if Python server is down for execute requests?
|
|
3190
|
+
// For now, just return error with hint
|
|
3191
|
+
res.status(503).json({
|
|
3192
|
+
success: false,
|
|
3193
|
+
error: 'Failed to connect to Python agents server',
|
|
3194
|
+
hint: 'Start with: npm run py:server',
|
|
3195
|
+
})
|
|
3196
|
+
}
|
|
3197
|
+
})
|
|
3198
|
+
|
|
3199
|
+
// ==================== OPENSPEC ARCHIVE ====================
|
|
3200
|
+
|
|
3201
|
+
// POST /api/flow/changes/:id/archive - Change를 아카이브로 이동
|
|
3202
|
+
app.post('/api/flow/changes/:id/archive', async (req, res) => {
|
|
3203
|
+
try {
|
|
3204
|
+
const changeId = req.params.id
|
|
3205
|
+
const { skipSpecs, force } = req.body
|
|
3206
|
+
const project = await getActiveProject()
|
|
3207
|
+
|
|
3208
|
+
if (!project) {
|
|
3209
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// openspec archive 명령어 실행
|
|
3213
|
+
const args = ['archive', changeId, '-y']
|
|
3214
|
+
if (skipSpecs) {
|
|
3215
|
+
args.push('--skip-specs')
|
|
3216
|
+
}
|
|
3217
|
+
if (force) {
|
|
3218
|
+
args.push('--no-validate')
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
let stdout = ''
|
|
3222
|
+
let stderr = ''
|
|
3223
|
+
let validationFailed = false
|
|
3224
|
+
const validationErrors: string[] = []
|
|
3225
|
+
|
|
3226
|
+
try {
|
|
3227
|
+
const result = await execAsync(`openspec ${args.join(' ')}`, {
|
|
3228
|
+
cwd: project.path,
|
|
3229
|
+
})
|
|
3230
|
+
stdout = result.stdout
|
|
3231
|
+
stderr = result.stderr
|
|
3232
|
+
} catch (execError) {
|
|
3233
|
+
// execAsync throws on non-zero exit code
|
|
3234
|
+
const error = execError as { stdout?: string; stderr?: string; message?: string }
|
|
3235
|
+
stdout = error.stdout || ''
|
|
3236
|
+
stderr = error.stderr || ''
|
|
3237
|
+
|
|
3238
|
+
// Check if it's a validation error
|
|
3239
|
+
if (stdout.includes('Validation failed') || stdout.includes('Validation errors')) {
|
|
3240
|
+
validationFailed = true
|
|
3241
|
+
// Parse validation errors from output
|
|
3242
|
+
const lines = stdout.split('\n')
|
|
3243
|
+
for (const line of lines) {
|
|
3244
|
+
if (line.includes('✗') || line.includes('⚠')) {
|
|
3245
|
+
validationErrors.push(line.trim())
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
} else {
|
|
3249
|
+
// Other error, rethrow
|
|
3250
|
+
throw execError
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// If validation failed and not forced, return error to let user decide
|
|
3255
|
+
if (validationFailed && !force) {
|
|
3256
|
+
console.log(`[Archive] Validation failed for ${changeId}, returning error to client`)
|
|
3257
|
+
return res.status(422).json({
|
|
3258
|
+
success: false,
|
|
3259
|
+
error: 'Validation failed',
|
|
3260
|
+
validationErrors,
|
|
3261
|
+
canForce: true,
|
|
3262
|
+
hint: 'Use force option to archive without validation, or fix the spec errors first',
|
|
3263
|
+
})
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
// Check if archive directory was actually created (file was moved)
|
|
3267
|
+
const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId)
|
|
3268
|
+
const originalPath = join(project.path, 'openspec', 'changes', changeId)
|
|
3269
|
+
|
|
3270
|
+
let filesMoved = false
|
|
3271
|
+
try {
|
|
3272
|
+
await import('fs/promises').then((fs) => fs.access(archivePath))
|
|
3273
|
+
filesMoved = true
|
|
3274
|
+
} catch {
|
|
3275
|
+
// Archive directory doesn't exist, check if original still exists
|
|
3276
|
+
try {
|
|
3277
|
+
await import('fs/promises').then((fs) => fs.access(originalPath))
|
|
3278
|
+
filesMoved = false
|
|
3279
|
+
} catch {
|
|
3280
|
+
// Neither exists - something went wrong
|
|
3281
|
+
filesMoved = false
|
|
3282
|
+
}
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
// DB에서 Change 상태 업데이트 (파일 이동 여부와 관계없이)
|
|
3286
|
+
await initTaskDb()
|
|
3287
|
+
const sqlite = getSqlite()
|
|
3288
|
+
const now = Date.now()
|
|
3289
|
+
|
|
3290
|
+
sqlite
|
|
3291
|
+
.prepare(
|
|
3292
|
+
`
|
|
3293
|
+
UPDATE changes SET status = 'archived', updated_at = ? WHERE id = ? AND project_id = ?
|
|
3294
|
+
`
|
|
3295
|
+
)
|
|
3296
|
+
.run(now, changeId, project.id)
|
|
3297
|
+
|
|
3298
|
+
// WebSocket으로 아카이브 완료 알림
|
|
3299
|
+
emit('change:archived', { changeId, projectId: project.id })
|
|
3300
|
+
|
|
3301
|
+
res.json({
|
|
3302
|
+
success: true,
|
|
3303
|
+
data: {
|
|
3304
|
+
changeId,
|
|
3305
|
+
archived: true,
|
|
3306
|
+
filesMoved,
|
|
3307
|
+
stdout: stdout.trim(),
|
|
3308
|
+
stderr: stderr.trim(),
|
|
3309
|
+
},
|
|
3310
|
+
})
|
|
3311
|
+
} catch (error) {
|
|
3312
|
+
console.error('Error archiving change:', error)
|
|
3313
|
+
res.status(500).json({
|
|
3314
|
+
success: false,
|
|
3315
|
+
error: error instanceof Error ? error.message : 'Failed to archive change',
|
|
3316
|
+
})
|
|
3317
|
+
}
|
|
3318
|
+
})
|
|
3319
|
+
|
|
3320
|
+
// POST /api/flow/changes/:id/fix-validation - 자동으로 validation 에러 수정
|
|
3321
|
+
app.post('/api/flow/changes/:id/fix-validation', async (req, res) => {
|
|
3322
|
+
try {
|
|
3323
|
+
const changeId = req.params.id
|
|
3324
|
+
const project = await getActiveProject()
|
|
3325
|
+
|
|
3326
|
+
if (!project) {
|
|
3327
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
const changeDir = join(project.path, 'openspec', 'changes', changeId)
|
|
3331
|
+
const fs = await import('fs/promises')
|
|
3332
|
+
|
|
3333
|
+
// Fix proposal.md - add "SHALL" to requirements that don't have it
|
|
3334
|
+
const proposalPath = join(changeDir, 'proposal.md')
|
|
3335
|
+
let proposalFixed = false
|
|
3336
|
+
try {
|
|
3337
|
+
const content = await fs.readFile(proposalPath, 'utf-8')
|
|
3338
|
+
// Find requirement lines without SHALL/MUST and add "SHALL"
|
|
3339
|
+
// Pattern: Lines starting with "- " in requirements section that don't have SHALL/MUST
|
|
3340
|
+
const lines = content.split('\n')
|
|
3341
|
+
const fixedLines = lines.map((line) => {
|
|
3342
|
+
// Check if it's a requirement-like line (starts with "- " and doesn't have SHALL/MUST)
|
|
3343
|
+
if (line.match(/^[-*]\s+/) && !line.match(/\b(SHALL|MUST)\b/i)) {
|
|
3344
|
+
// Check if it's in a requirements context (has keywords like "requirement", "feature", etc.)
|
|
3345
|
+
if (line.match(/^[-*]\s+\w/)) {
|
|
3346
|
+
// Insert "SHALL" after the bullet point
|
|
3347
|
+
return line.replace(/^([-*]\s+)/, '$1The system SHALL ')
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
return line
|
|
3351
|
+
})
|
|
3352
|
+
const newContent = fixedLines.join('\n')
|
|
3353
|
+
if (newContent !== content) {
|
|
3354
|
+
await fs.writeFile(proposalPath, newContent)
|
|
3355
|
+
proposalFixed = true
|
|
3356
|
+
}
|
|
3357
|
+
} catch {
|
|
3358
|
+
// proposal.md not found or not readable
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
// Fix spec files in specs/ subdirectory
|
|
3362
|
+
const specsDir = join(changeDir, 'specs')
|
|
3363
|
+
let specsFixed = 0
|
|
3364
|
+
try {
|
|
3365
|
+
const specEntries = await fs.readdir(specsDir, { withFileTypes: true })
|
|
3366
|
+
for (const entry of specEntries) {
|
|
3367
|
+
if (!entry.isDirectory()) continue
|
|
3368
|
+
const specPath = join(specsDir, entry.name, 'spec.md')
|
|
3369
|
+
try {
|
|
3370
|
+
const content = await fs.readFile(specPath, 'utf-8')
|
|
3371
|
+
// Find requirement headings and their content
|
|
3372
|
+
// Pattern: ### Requirement: ... followed by content
|
|
3373
|
+
const lines = content.split('\n')
|
|
3374
|
+
const fixedLines = lines.map((line, _i) => {
|
|
3375
|
+
// If this is a requirement heading
|
|
3376
|
+
if (line.match(/^###\s+Requirement:/)) {
|
|
3377
|
+
// Check if the requirement text contains SHALL/MUST
|
|
3378
|
+
if (!line.match(/\b(SHALL|MUST)\b/i)) {
|
|
3379
|
+
// Insert "SHALL" into the requirement
|
|
3380
|
+
return line.replace(/(###\s+Requirement:\s*)(.+)/, (_, prefix, reqText) => {
|
|
3381
|
+
// If the text is like "Feature Name", convert to "The system SHALL provide Feature Name"
|
|
3382
|
+
return `${prefix}The system SHALL provide ${reqText}`
|
|
3383
|
+
})
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
return line
|
|
3387
|
+
})
|
|
3388
|
+
const newContent = fixedLines.join('\n')
|
|
3389
|
+
if (newContent !== content) {
|
|
3390
|
+
await fs.writeFile(specPath, newContent)
|
|
3391
|
+
specsFixed++
|
|
3392
|
+
}
|
|
3393
|
+
} catch {
|
|
3394
|
+
// spec.md not found
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
} catch {
|
|
3398
|
+
// specs directory not found
|
|
3399
|
+
}
|
|
3400
|
+
|
|
3401
|
+
res.json({
|
|
3402
|
+
success: true,
|
|
3403
|
+
data: {
|
|
3404
|
+
changeId,
|
|
3405
|
+
proposalFixed,
|
|
3406
|
+
specsFixed,
|
|
3407
|
+
message:
|
|
3408
|
+
proposalFixed || specsFixed > 0
|
|
3409
|
+
? `Fixed validation errors: proposal=${proposalFixed}, specs=${specsFixed}`
|
|
3410
|
+
: 'No fixes needed or possible',
|
|
3411
|
+
},
|
|
3412
|
+
})
|
|
3413
|
+
} catch (error) {
|
|
3414
|
+
console.error('Error fixing validation:', error)
|
|
3415
|
+
res.status(500).json({
|
|
3416
|
+
success: false,
|
|
3417
|
+
error: error instanceof Error ? error.message : 'Failed to fix validation',
|
|
3418
|
+
})
|
|
3419
|
+
}
|
|
3420
|
+
})
|
|
3421
|
+
|
|
3422
|
+
// ==================== DIAGRAM API ====================
|
|
3423
|
+
|
|
3424
|
+
// GET /api/diagram/context - Get file tree and README for diagram generation
|
|
3425
|
+
app.get('/api/diagram/context', async (req, res) => {
|
|
3426
|
+
try {
|
|
3427
|
+
const project = await getActiveProject()
|
|
3428
|
+
if (!project) {
|
|
3429
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
const projectPath = (req.query.path as string) || project.path
|
|
3433
|
+
const maxDepth = parseInt(req.query.maxDepth as string) || 10
|
|
3434
|
+
|
|
3435
|
+
const core = await getGitdiagramCore()
|
|
3436
|
+
const [fileTree, readme] = await Promise.all([
|
|
3437
|
+
core.generateFileTree(projectPath, { maxDepth }),
|
|
3438
|
+
core.readReadme(projectPath),
|
|
3439
|
+
])
|
|
3440
|
+
|
|
3441
|
+
res.json({
|
|
3442
|
+
success: true,
|
|
3443
|
+
data: {
|
|
3444
|
+
projectPath,
|
|
3445
|
+
fileTree,
|
|
3446
|
+
readme,
|
|
3447
|
+
fileTreeLines: fileTree.split('\n').length,
|
|
3448
|
+
},
|
|
3449
|
+
})
|
|
3450
|
+
} catch (error) {
|
|
3451
|
+
console.error('Error getting diagram context:', error)
|
|
3452
|
+
res.status(500).json({
|
|
3453
|
+
success: false,
|
|
3454
|
+
error: error instanceof Error ? error.message : 'Failed to get diagram context',
|
|
3455
|
+
})
|
|
3456
|
+
}
|
|
3457
|
+
})
|
|
3458
|
+
|
|
3459
|
+
// POST /api/diagram/generate - Generate diagram using LLM
|
|
3460
|
+
app.post('/api/diagram/generate', async (req, res) => {
|
|
3461
|
+
try {
|
|
3462
|
+
const project = await getActiveProject()
|
|
3463
|
+
if (!project) {
|
|
3464
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
const { instructions } = req.body
|
|
3468
|
+
const projectPath = req.body.projectPath || project.path
|
|
3469
|
+
|
|
3470
|
+
// Get gitdiagram-core functions
|
|
3471
|
+
const core = await getGitdiagramCore()
|
|
3472
|
+
|
|
3473
|
+
// Check for API key - priority: Gemini > Claude > OpenAI (Gemini first as it's commonly available)
|
|
3474
|
+
let apiKey: string | undefined
|
|
3475
|
+
let provider: 'claude' | 'openai' | 'gemini'
|
|
3476
|
+
|
|
3477
|
+
// Debug log for environment variables
|
|
3478
|
+
console.log('[Diagram] Available API keys:', {
|
|
3479
|
+
anthropic: !!process.env.ANTHROPIC_API_KEY,
|
|
3480
|
+
openai: !!process.env.OPENAI_API_KEY,
|
|
3481
|
+
google: !!process.env.GOOGLE_API_KEY,
|
|
3482
|
+
gemini: !!process.env.GEMINI_API_KEY,
|
|
3483
|
+
})
|
|
3484
|
+
|
|
3485
|
+
if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) {
|
|
3486
|
+
apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY
|
|
3487
|
+
provider = 'gemini'
|
|
3488
|
+
} else if (process.env.ANTHROPIC_API_KEY) {
|
|
3489
|
+
apiKey = process.env.ANTHROPIC_API_KEY
|
|
3490
|
+
provider = 'claude'
|
|
3491
|
+
} else if (process.env.OPENAI_API_KEY) {
|
|
3492
|
+
apiKey = process.env.OPENAI_API_KEY
|
|
3493
|
+
provider = 'openai'
|
|
3494
|
+
} else {
|
|
3495
|
+
// Return a generated diagram based on file structure analysis (no LLM)
|
|
3496
|
+
const [fileTree] = await Promise.all([core.generateFileTree(projectPath, { maxDepth: 8 })])
|
|
3497
|
+
const simpleDiagram = generateSimpleDiagram(fileTree, project.name)
|
|
3498
|
+
return res.json({
|
|
3499
|
+
success: true,
|
|
3500
|
+
data: {
|
|
3501
|
+
mermaidCode: simpleDiagram,
|
|
3502
|
+
projectPath,
|
|
3503
|
+
generated: 'simple', // Indicates no LLM was used
|
|
3504
|
+
message: 'Generated simple diagram (no LLM API key configured)',
|
|
3505
|
+
},
|
|
3506
|
+
})
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
// Use LLM to generate diagram
|
|
3510
|
+
console.log(`[Diagram] Using provider: ${provider}`)
|
|
3511
|
+
const adapter = core.createLLMAdapter(provider, { apiKey })
|
|
3512
|
+
|
|
3513
|
+
const result = await core.generateDiagram(projectPath, {
|
|
3514
|
+
llm: adapter,
|
|
3515
|
+
instructions,
|
|
3516
|
+
})
|
|
3517
|
+
|
|
3518
|
+
res.json({
|
|
3519
|
+
success: true,
|
|
3520
|
+
data: {
|
|
3521
|
+
mermaidCode: result.mermaidCode,
|
|
3522
|
+
projectPath,
|
|
3523
|
+
generated: 'llm',
|
|
3524
|
+
explanation: result.explanation,
|
|
3525
|
+
},
|
|
3526
|
+
})
|
|
3527
|
+
} catch (error) {
|
|
3528
|
+
console.error('Error generating diagram:', error)
|
|
3529
|
+
res.status(500).json({
|
|
3530
|
+
success: false,
|
|
3531
|
+
error: error instanceof Error ? error.message : 'Failed to generate diagram',
|
|
3532
|
+
})
|
|
3533
|
+
}
|
|
3534
|
+
})
|
|
3535
|
+
|
|
3536
|
+
// Helper function to generate simple diagram from file tree (no LLM)
|
|
3537
|
+
function generateSimpleDiagram(fileTree: string, projectName: string): string {
|
|
3538
|
+
const lines = fileTree.split('\n').filter((l) => l.trim())
|
|
3539
|
+
const dirs = new Set<string>()
|
|
3540
|
+
const files: string[] = []
|
|
3541
|
+
|
|
3542
|
+
// Parse top-level directories and key files
|
|
3543
|
+
for (const line of lines) {
|
|
3544
|
+
const depth = (line.match(/^[│├└─\s]*/)?.[0] || '').length / 4
|
|
3545
|
+
const name = line.replace(/^[│├└─\s]+/, '').trim()
|
|
3546
|
+
|
|
3547
|
+
if (depth === 0 && name && !name.startsWith('.')) {
|
|
3548
|
+
if (!name.includes('.')) {
|
|
3549
|
+
dirs.add(name)
|
|
3550
|
+
} else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.json')) {
|
|
3551
|
+
files.push(name)
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
// Build diagram
|
|
3557
|
+
let diagram = `flowchart TD
|
|
3558
|
+
subgraph Project["${projectName}"]
|
|
3559
|
+
`
|
|
3560
|
+
|
|
3561
|
+
const nodeIds: string[] = []
|
|
3562
|
+
|
|
3563
|
+
// Add directories
|
|
3564
|
+
for (const dir of dirs) {
|
|
3565
|
+
const nodeId = dir.replace(/[^a-zA-Z0-9]/g, '')
|
|
3566
|
+
nodeIds.push(nodeId)
|
|
3567
|
+
diagram += ` ${nodeId}[📁 ${dir}]\n`
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
// Add key files
|
|
3571
|
+
for (const file of files.slice(0, 5)) {
|
|
3572
|
+
const nodeId = file.replace(/[^a-zA-Z0-9]/g, '')
|
|
3573
|
+
nodeIds.push(nodeId)
|
|
3574
|
+
diagram += ` ${nodeId}[📄 ${file}]\n`
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
diagram += ` end\n`
|
|
3578
|
+
|
|
3579
|
+
// Add some connections based on common patterns
|
|
3580
|
+
if (nodeIds.includes('src') && nodeIds.includes('server')) {
|
|
3581
|
+
diagram += ` src --> server\n`
|
|
3582
|
+
}
|
|
3583
|
+
if (nodeIds.includes('src') && nodeIds.includes('components')) {
|
|
3584
|
+
diagram += ` src --> components\n`
|
|
3585
|
+
}
|
|
3586
|
+
if (nodeIds.includes('server') && nodeIds.includes('api')) {
|
|
3587
|
+
diagram += ` server --> api\n`
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
// Add styles
|
|
3591
|
+
diagram += `
|
|
3592
|
+
style Project fill:#f5f5f5,stroke:#333
|
|
3593
|
+
`
|
|
3594
|
+
|
|
3595
|
+
return diagram
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
// POST /api/diagram/validate - Validate Mermaid syntax
|
|
3599
|
+
app.post('/api/diagram/validate', async (req, res) => {
|
|
3600
|
+
try {
|
|
3601
|
+
const { code } = req.body
|
|
3602
|
+
if (!code) {
|
|
3603
|
+
return res.status(400).json({ success: false, error: 'No code provided' })
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
const core = await getGitdiagramCore()
|
|
3607
|
+
const validation = core.validateMermaidSyntax(code)
|
|
3608
|
+
const clickEvents = core.extractClickEvents(code)
|
|
3609
|
+
|
|
3610
|
+
res.json({
|
|
3611
|
+
success: true,
|
|
3612
|
+
data: {
|
|
3613
|
+
valid: validation.valid,
|
|
3614
|
+
errors: validation.errors,
|
|
3615
|
+
warnings: validation.warnings,
|
|
3616
|
+
clickEvents,
|
|
3617
|
+
},
|
|
3618
|
+
})
|
|
3619
|
+
} catch (error) {
|
|
3620
|
+
console.error('Error validating diagram:', error)
|
|
3621
|
+
res.status(500).json({
|
|
3622
|
+
success: false,
|
|
3623
|
+
error: error instanceof Error ? error.message : 'Failed to validate diagram',
|
|
3624
|
+
})
|
|
3625
|
+
}
|
|
3626
|
+
})
|
|
3627
|
+
|
|
3628
|
+
// GET /api/diagram/change/:changeId - Get diagram context for an OpenSpec change
|
|
3629
|
+
app.get('/api/diagram/change/:changeId', async (req, res) => {
|
|
3630
|
+
try {
|
|
3631
|
+
const { changeId } = req.params
|
|
3632
|
+
const project = await getActiveProject()
|
|
3633
|
+
|
|
3634
|
+
if (!project) {
|
|
3635
|
+
return res.status(400).json({ success: false, error: 'No active project' })
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
const changeDir = join(project.path, 'openspec', 'changes', changeId)
|
|
3639
|
+
|
|
3640
|
+
// Read change documents
|
|
3641
|
+
let proposal = ''
|
|
3642
|
+
let spec = ''
|
|
3643
|
+
let tasks = ''
|
|
3644
|
+
|
|
3645
|
+
try {
|
|
3646
|
+
proposal = await readFile(join(changeDir, 'proposal.md'), 'utf-8')
|
|
3647
|
+
} catch {
|
|
3648
|
+
/* no proposal */
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
try {
|
|
3652
|
+
spec = await readFile(join(changeDir, 'spec.md'), 'utf-8')
|
|
3653
|
+
} catch {
|
|
3654
|
+
/* no spec */
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
try {
|
|
3658
|
+
tasks = await readFile(join(changeDir, 'tasks.md'), 'utf-8')
|
|
3659
|
+
} catch {
|
|
3660
|
+
/* no tasks */
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
// Extract affected files
|
|
3664
|
+
const affectedFiles: string[] = []
|
|
3665
|
+
const filePattern = /`([^`]+\.(ts|tsx|js|jsx|py|go|rs|java|md))`/g
|
|
3666
|
+
|
|
3667
|
+
for (const content of [proposal, spec, tasks]) {
|
|
3668
|
+
let match
|
|
3669
|
+
while ((match = filePattern.exec(content)) !== null) {
|
|
3670
|
+
if (!affectedFiles.includes(match[1])) {
|
|
3671
|
+
affectedFiles.push(match[1])
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
res.json({
|
|
3677
|
+
success: true,
|
|
3678
|
+
data: {
|
|
3679
|
+
changeId,
|
|
3680
|
+
affectedFiles,
|
|
3681
|
+
hasProposal: !!proposal,
|
|
3682
|
+
hasSpec: !!spec,
|
|
3683
|
+
hasTasks: !!tasks,
|
|
3684
|
+
},
|
|
3685
|
+
})
|
|
3686
|
+
} catch (error) {
|
|
3687
|
+
console.error('Error getting change diagram context:', error)
|
|
3688
|
+
res.status(500).json({
|
|
3689
|
+
success: false,
|
|
3690
|
+
error: error instanceof Error ? error.message : 'Failed to get change context',
|
|
3691
|
+
})
|
|
3692
|
+
}
|
|
3693
|
+
})
|