@assistkick/create 1.7.0 → 1.8.0

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.
Files changed (200) hide show
  1. package/dist/bin/create.js +0 -0
  2. package/package.json +9 -7
  3. package/templates/assistkick-product-system/.env.example +1 -0
  4. package/templates/assistkick-product-system/local.db +0 -0
  5. package/templates/assistkick-product-system/package.json +4 -2
  6. package/templates/assistkick-product-system/packages/backend/package.json +2 -0
  7. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
  9. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
  10. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
  11. package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
  13. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
  14. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
  15. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
  16. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
  17. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
  18. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
  19. package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
  20. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
  21. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
  22. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
  23. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
  24. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
  25. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
  26. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
  27. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
  28. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
  29. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
  30. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
  31. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
  32. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
  33. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
  34. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
  35. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
  41. package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
  42. package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
  43. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
  44. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
  45. package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
  46. package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
  47. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
  48. package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
  49. package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
  50. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
  51. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
  52. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
  53. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
  54. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
  55. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
  56. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
  57. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
  58. package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
  59. package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
  60. package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
  61. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +202 -171
  62. package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
  63. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
  64. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
  65. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
  66. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
  67. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
  68. package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
  69. package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
  70. package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
  71. package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
  82. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
  85. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
  86. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
  87. package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
  88. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
  89. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
  92. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
  97. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
  99. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
  102. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
  105. package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
  106. package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
  107. package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
  108. package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
  109. package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
  110. package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
  111. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
  112. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
  113. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
  114. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
  116. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
  119. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
  120. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
  121. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
  128. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
  129. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
  130. package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
  131. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
  132. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
  133. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
  134. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
  135. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
  136. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
  137. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
  138. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
  139. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
  140. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
  141. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
  142. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
  143. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
  144. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
  145. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
  146. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
  147. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
  148. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
  149. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
  150. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  151. package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
  152. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
  153. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
  154. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
  155. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
  156. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
  157. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
  158. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
  159. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
  160. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
  161. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
  162. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
  163. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
  164. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  165. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
  166. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
  167. package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
  168. package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
  169. package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
  170. package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
  171. package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
  172. package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
  173. package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
  174. package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
  175. package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
  176. package/templates/assistkick-product-system/packages/video/index.ts +4 -0
  177. package/templates/assistkick-product-system/packages/video/package.json +28 -0
  178. package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
  179. package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
  180. package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
  181. package/templates/assistkick-product-system/packages/video/style.css +1 -0
  182. package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
  183. package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
  184. package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
  185. package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
  186. package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
  187. package/templates/skills/assistkick-developer/SKILL.md +3 -0
  188. package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
  189. package/templates/skills/product-system/graph.json +1890 -0
  190. package/templates/skills/product-system/kanban.json +304 -0
  191. package/templates/skills/product-system/nodes/comp_001.md +56 -0
  192. package/templates/skills/product-system/nodes/comp_002.md +57 -0
  193. package/templates/skills/product-system/nodes/data_001.md +51 -0
  194. package/templates/skills/product-system/nodes/data_002.md +40 -0
  195. package/templates/skills/product-system/nodes/data_004.md +38 -0
  196. package/templates/skills/product-system/nodes/dec_001.md +34 -0
  197. package/templates/skills/product-system/nodes/dec_016.md +32 -0
  198. package/templates/skills/product-system/nodes/feat_008.md +30 -0
  199. package/templates/skills/video-composition-agent/SKILL.md +232 -0
  200. package/templates/skills/video-script-writer/SKILL.md +136 -0
@@ -0,0 +1,256 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { VideoRenderService } from './video_render_service.js';
4
+
5
+ describe('VideoRenderService', () => {
6
+ let logs: string[];
7
+ let dbRows: Map<string, any>;
8
+ let deletedIds: string[];
9
+
10
+ const makeMockDb = () => {
11
+ return {
12
+ select: () => ({
13
+ from: () => ({
14
+ where: (condition: any) => {
15
+ // Return matching rows based on condition
16
+ const results: any[] = [];
17
+ for (const row of dbRows.values()) {
18
+ results.push(row);
19
+ }
20
+ // Filter by the mock condition — simple implementation
21
+ return Promise.resolve(results.filter(r => {
22
+ // We'll set a flag for what we're querying
23
+ if ((mockDb as any)._lastQueryId) {
24
+ return r.id === (mockDb as any)._lastQueryId;
25
+ }
26
+ if ((mockDb as any)._lastQueryProjectId) {
27
+ return r.projectId === (mockDb as any)._lastQueryProjectId;
28
+ }
29
+ return true;
30
+ }));
31
+ },
32
+ orderBy: () => {
33
+ const results = Array.from(dbRows.values());
34
+ // Sort by createdAt desc
35
+ results.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
36
+ return Promise.resolve(results);
37
+ },
38
+ }),
39
+ }),
40
+ insert: () => ({
41
+ values: (row: any) => {
42
+ dbRows.set(row.id, row);
43
+ return Promise.resolve();
44
+ },
45
+ }),
46
+ update: () => ({
47
+ set: (fields: any) => ({
48
+ where: (condition: any) => {
49
+ // Update matching rows
50
+ for (const [id, row] of dbRows.entries()) {
51
+ Object.assign(row, fields);
52
+ }
53
+ return Promise.resolve();
54
+ },
55
+ }),
56
+ }),
57
+ delete: () => ({
58
+ where: (condition: any) => {
59
+ // Delete the row matching _lastQueryId
60
+ const id = (mockDb as any)._lastDeleteId;
61
+ if (id) {
62
+ dbRows.delete(id);
63
+ deletedIds.push(id);
64
+ }
65
+ return Promise.resolve();
66
+ },
67
+ }),
68
+ };
69
+ };
70
+
71
+ let mockDb: any;
72
+
73
+ const makeService = () => {
74
+ logs = [];
75
+ dbRows = new Map();
76
+ deletedIds = [];
77
+ mockDb = null;
78
+
79
+ // We need a more precise mock. Let's use a simpler approach:
80
+ // Directly test the data transformation logic.
81
+ return new VideoRenderService({
82
+ getDb: () => mockDb,
83
+ workspacesDir: '/data/workspaces',
84
+ bundleOutputDir: '/data/bundle',
85
+ log: (tag: string, ...args: any[]) => { logs.push(`[${tag}] ${args.join(' ')}`); },
86
+ });
87
+ };
88
+
89
+ describe('listRenders', () => {
90
+ it('returns renders sorted by createdAt desc', async () => {
91
+ const svc = makeService();
92
+
93
+ const rows = [
94
+ {
95
+ id: 'r1', projectId: 'proj1', featureId: 'f1', compositionId: 'comp1',
96
+ status: 'complete', progress: 100, filePath: '/data/r1.mp4', fileSize: 1024,
97
+ durationSeconds: 10.5, error: null, resolution: '1920x1080',
98
+ createdAt: '2026-03-10T10:00:00Z', completedAt: '2026-03-10T10:05:00Z',
99
+ },
100
+ {
101
+ id: 'r2', projectId: 'proj1', featureId: 'f1', compositionId: 'comp2',
102
+ status: 'rendering', progress: 45, filePath: null, fileSize: null,
103
+ durationSeconds: null, error: null, resolution: '1280x720',
104
+ createdAt: '2026-03-10T11:00:00Z', completedAt: null,
105
+ },
106
+ ];
107
+
108
+ // Mock the db to return rows sorted desc
109
+ mockDb = {
110
+ select: () => ({
111
+ from: () => ({
112
+ where: () => ({
113
+ orderBy: () => Promise.resolve([rows[1], rows[0]]), // r2 first (newer)
114
+ }),
115
+ }),
116
+ }),
117
+ };
118
+
119
+ const result = await svc.listRenders('proj1');
120
+ assert.equal(result.length, 2);
121
+ assert.equal(result[0].id, 'r2');
122
+ assert.equal(result[0].status, 'rendering');
123
+ assert.equal(result[0].progress, 45);
124
+ assert.equal(result[1].id, 'r1');
125
+ assert.equal(result[1].status, 'complete');
126
+ assert.equal(result[1].fileSize, 1024);
127
+ assert.equal(result[1].durationSeconds, 10.5);
128
+ });
129
+
130
+ it('returns empty array when no renders exist', async () => {
131
+ const svc = makeService();
132
+
133
+ mockDb = {
134
+ select: () => ({
135
+ from: () => ({
136
+ where: () => ({
137
+ orderBy: () => Promise.resolve([]),
138
+ }),
139
+ }),
140
+ }),
141
+ };
142
+
143
+ const result = await svc.listRenders('proj1');
144
+ assert.deepEqual(result, []);
145
+ });
146
+ });
147
+
148
+ describe('deleteRender', () => {
149
+ it('returns false when render does not exist', async () => {
150
+ const svc = makeService();
151
+
152
+ mockDb = {
153
+ select: () => ({
154
+ from: () => ({
155
+ where: () => Promise.resolve([]),
156
+ }),
157
+ }),
158
+ };
159
+
160
+ const result = await svc.deleteRender('nonexistent');
161
+ assert.equal(result, false);
162
+ });
163
+
164
+ it('deletes db row and logs when file does not exist on disk', async () => {
165
+ const svc = makeService();
166
+
167
+ let deletedFromDb = false;
168
+ mockDb = {
169
+ select: () => ({
170
+ from: () => ({
171
+ where: () => Promise.resolve([{
172
+ id: 'r1', filePath: '/nonexistent/path.mp4',
173
+ projectId: 'proj1', compositionId: 'comp1', status: 'complete',
174
+ }]),
175
+ }),
176
+ }),
177
+ delete: () => ({
178
+ where: () => { deletedFromDb = true; return Promise.resolve(); },
179
+ }),
180
+ };
181
+
182
+ const result = await svc.deleteRender('r1');
183
+ assert.equal(result, true);
184
+ assert.equal(deletedFromDb, true);
185
+ assert.ok(logs.some(l => l.includes('Deleted render record r1')));
186
+ });
187
+
188
+ it('deletes db row when filePath is null', async () => {
189
+ const svc = makeService();
190
+
191
+ let deletedFromDb = false;
192
+ mockDb = {
193
+ select: () => ({
194
+ from: () => ({
195
+ where: () => Promise.resolve([{
196
+ id: 'r1', filePath: null,
197
+ projectId: 'proj1', compositionId: 'comp1', status: 'queued',
198
+ }]),
199
+ }),
200
+ }),
201
+ delete: () => ({
202
+ where: () => { deletedFromDb = true; return Promise.resolve(); },
203
+ }),
204
+ };
205
+
206
+ const result = await svc.deleteRender('r1');
207
+ assert.equal(result, true);
208
+ assert.equal(deletedFromDb, true);
209
+ });
210
+ });
211
+
212
+ describe('getStatus', () => {
213
+ it('returns null when render does not exist', async () => {
214
+ const svc = makeService();
215
+
216
+ mockDb = {
217
+ select: () => ({
218
+ from: () => ({
219
+ where: () => Promise.resolve([]),
220
+ }),
221
+ }),
222
+ };
223
+
224
+ const result = await svc.getStatus('nonexistent');
225
+ assert.equal(result, null);
226
+ });
227
+
228
+ it('maps db row to RenderStatus correctly', async () => {
229
+ const svc = makeService();
230
+
231
+ mockDb = {
232
+ select: () => ({
233
+ from: () => ({
234
+ where: () => Promise.resolve([{
235
+ id: 'r1', status: 'complete', progress: 100,
236
+ filePath: '/data/r1.mp4', fileSize: 2048,
237
+ durationSeconds: 15.3, error: null,
238
+ compositionId: 'my-comp', resolution: '1920x1080',
239
+ createdAt: '2026-03-10T10:00:00Z', completedAt: '2026-03-10T10:05:00Z',
240
+ }]),
241
+ }),
242
+ }),
243
+ };
244
+
245
+ const result = await svc.getStatus('r1');
246
+ assert.ok(result);
247
+ assert.equal(result.id, 'r1');
248
+ assert.equal(result.status, 'complete');
249
+ assert.equal(result.progress, 100);
250
+ assert.equal(result.fileSize, 2048);
251
+ assert.equal(result.durationSeconds, 15.3);
252
+ assert.equal(result.compositionId, 'my-comp');
253
+ assert.equal(result.resolution, '1920x1080');
254
+ });
255
+ });
256
+ });
@@ -0,0 +1,258 @@
1
+ /**
2
+ * VideoRenderService — triggers Remotion rendering via @remotion/renderer,
3
+ * tracks render status (queued/rendering/complete/failed) in video_renders table,
4
+ * and stores output videos on the persistent volume.
5
+ *
6
+ * Output path: /data/workspaces/<projectId>/media/<featureId>/outputs/{renderId}.mp4
7
+ */
8
+
9
+ import { randomUUID } from 'node:crypto';
10
+ import { existsSync, mkdirSync, statSync, unlinkSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { renderMedia, selectComposition } from '@remotion/renderer';
13
+ import { eq, desc, and } from 'drizzle-orm';
14
+ import { videoRenders } from '@assistkick/shared/db/schema.js';
15
+
16
+ interface VideoRenderServiceDeps {
17
+ getDb: () => any;
18
+ workspacesDir: string;
19
+ bundleOutputDir: string;
20
+ log: (tag: string, ...args: any[]) => void;
21
+ }
22
+
23
+ export interface RenderRequest {
24
+ compositionId: string;
25
+ projectId: string;
26
+ featureId: string;
27
+ resolution?: string;
28
+ aspectRatio?: string;
29
+ fileOutputPrefix?: string;
30
+ }
31
+
32
+ export interface RenderStatus {
33
+ id: string;
34
+ status: 'queued' | 'rendering' | 'complete' | 'failed';
35
+ progress: number;
36
+ filePath: string | null;
37
+ fileSize: number | null;
38
+ durationSeconds: number | null;
39
+ error: string | null;
40
+ compositionId: string;
41
+ resolution: string | null;
42
+ createdAt: string;
43
+ completedAt: string | null;
44
+ }
45
+
46
+ export class VideoRenderService {
47
+ private readonly getDb: VideoRenderServiceDeps['getDb'];
48
+ private readonly workspacesDir: string;
49
+ private readonly bundleOutputDir: string;
50
+ private readonly log: VideoRenderServiceDeps['log'];
51
+
52
+ constructor({ getDb, workspacesDir, bundleOutputDir, log }: VideoRenderServiceDeps) {
53
+ this.getDb = getDb;
54
+ this.workspacesDir = workspacesDir;
55
+ this.bundleOutputDir = bundleOutputDir;
56
+ this.log = log;
57
+ }
58
+
59
+ /** Get the outputs directory for a project/feature. */
60
+ private getOutputDir = (projectId: string, featureId: string): string => {
61
+ return join(this.workspacesDir, projectId, 'media', featureId, 'outputs');
62
+ };
63
+
64
+ /** Parse resolution string like "1920x1080" into width/height. */
65
+ private parseResolution = (resolution?: string): { width: number; height: number } => {
66
+ if (!resolution) return { width: 1920, height: 1080 };
67
+ const parts = resolution.split('x');
68
+ if (parts.length !== 2) return { width: 1920, height: 1080 };
69
+ const width = parseInt(parts[0], 10);
70
+ const height = parseInt(parts[1], 10);
71
+ if (isNaN(width) || isNaN(height)) return { width: 1920, height: 1080 };
72
+ return { width, height };
73
+ };
74
+
75
+ /**
76
+ * Start a new render. Creates a queued record and kicks off async rendering.
77
+ * Returns the render ID immediately.
78
+ */
79
+ startRender = async (request: RenderRequest): Promise<RenderStatus> => {
80
+ const bundlePath = join(this.bundleOutputDir, 'index.html');
81
+ if (!existsSync(bundlePath)) {
82
+ throw new Error('Remotion bundle not found. Run a bundle rebuild first.');
83
+ }
84
+
85
+ const id = randomUUID();
86
+ const now = new Date().toISOString();
87
+
88
+ const db = this.getDb();
89
+ await db.insert(videoRenders).values({
90
+ id,
91
+ projectId: request.projectId,
92
+ featureId: request.featureId,
93
+ compositionId: request.compositionId,
94
+ status: 'queued',
95
+ progress: 0,
96
+ resolution: request.resolution || '1920x1080',
97
+ createdAt: now,
98
+ });
99
+
100
+ this.log('RENDER', `Queued render ${id} for composition=${request.compositionId} project=${request.projectId}`);
101
+
102
+ // Start rendering asynchronously — don't await
103
+ this.executeRender(id, request).catch((err) => {
104
+ this.log('RENDER', `Render ${id} async error: ${err.message}`);
105
+ });
106
+
107
+ return this.getStatus(id) as Promise<RenderStatus>;
108
+ };
109
+
110
+ /** Get the status of a render by ID. */
111
+ getStatus = async (renderId: string): Promise<RenderStatus | null> => {
112
+ const db = this.getDb();
113
+ const rows = await db.select().from(videoRenders).where(eq(videoRenders.id, renderId));
114
+ if (rows.length === 0) return null;
115
+
116
+ const row = rows[0];
117
+ return {
118
+ id: row.id,
119
+ status: row.status as RenderStatus['status'],
120
+ progress: row.progress,
121
+ filePath: row.filePath,
122
+ fileSize: row.fileSize,
123
+ durationSeconds: row.durationSeconds,
124
+ error: row.error,
125
+ compositionId: row.compositionId,
126
+ resolution: row.resolution,
127
+ createdAt: row.createdAt,
128
+ completedAt: row.completedAt,
129
+ };
130
+ };
131
+
132
+ /** List all renders for a project, sorted by createdAt desc. */
133
+ listRenders = async (projectId: string): Promise<RenderStatus[]> => {
134
+ const db = this.getDb();
135
+ const rows = await db.select()
136
+ .from(videoRenders)
137
+ .where(eq(videoRenders.projectId, projectId))
138
+ .orderBy(desc(videoRenders.createdAt));
139
+
140
+ return rows.map((row: any) => ({
141
+ id: row.id,
142
+ status: row.status as RenderStatus['status'],
143
+ progress: row.progress,
144
+ filePath: row.filePath,
145
+ fileSize: row.fileSize,
146
+ durationSeconds: row.durationSeconds,
147
+ error: row.error,
148
+ compositionId: row.compositionId,
149
+ resolution: row.resolution,
150
+ createdAt: row.createdAt,
151
+ completedAt: row.completedAt,
152
+ }));
153
+ };
154
+
155
+ /** Delete a render record and its MP4 file from disk. */
156
+ deleteRender = async (renderId: string): Promise<boolean> => {
157
+ const db = this.getDb();
158
+ const rows = await db.select().from(videoRenders).where(eq(videoRenders.id, renderId));
159
+ if (rows.length === 0) return false;
160
+
161
+ const row = rows[0];
162
+
163
+ // Delete the MP4 file from disk if it exists
164
+ if (row.filePath && existsSync(row.filePath)) {
165
+ try {
166
+ unlinkSync(row.filePath);
167
+ this.log('RENDER', `Deleted file for render ${renderId}: ${row.filePath}`);
168
+ } catch (err: any) {
169
+ this.log('RENDER', `Failed to delete file for render ${renderId}: ${err.message}`);
170
+ }
171
+ }
172
+
173
+ await db.delete(videoRenders).where(eq(videoRenders.id, renderId));
174
+ this.log('RENDER', `Deleted render record ${renderId}`);
175
+ return true;
176
+ };
177
+
178
+ /** Get the absolute file path for a completed render's output. */
179
+ getFilePath = async (renderId: string): Promise<string | null> => {
180
+ const status = await this.getStatus(renderId);
181
+ if (!status || status.status !== 'complete' || !status.filePath) return null;
182
+ return status.filePath;
183
+ };
184
+
185
+ /** Execute the Remotion render for a given render record. */
186
+ private executeRender = async (renderId: string, request: RenderRequest): Promise<void> => {
187
+ const db = this.getDb();
188
+
189
+ try {
190
+ // Mark as rendering
191
+ await db.update(videoRenders)
192
+ .set({ status: 'rendering' })
193
+ .where(eq(videoRenders.id, renderId));
194
+
195
+ const { width, height } = this.parseResolution(request.resolution);
196
+ const outputDir = this.getOutputDir(request.projectId, request.featureId);
197
+ mkdirSync(outputDir, { recursive: true });
198
+
199
+ const prefix = request.fileOutputPrefix || renderId;
200
+ const outputPath = join(outputDir, `${prefix}.mp4`);
201
+
202
+ const mediaBasePath = join(this.workspacesDir, request.projectId, 'media', request.featureId);
203
+
204
+ this.log('RENDER', `Starting render ${renderId}: ${request.compositionId} → ${outputPath}`);
205
+
206
+ const composition = await selectComposition({
207
+ serveUrl: this.bundleOutputDir,
208
+ id: request.compositionId,
209
+ inputProps: { mediaBasePath },
210
+ });
211
+
212
+ await renderMedia({
213
+ composition: { ...composition, width, height },
214
+ serveUrl: this.bundleOutputDir,
215
+ codec: 'h264',
216
+ outputLocation: outputPath,
217
+ inputProps: { mediaBasePath },
218
+ chromiumOptions: {
219
+ enableMultiProcessOnLinux: true,
220
+ },
221
+ onProgress: ({ progress }) => {
222
+ // Update progress in DB periodically — fire and forget
223
+ db.update(videoRenders)
224
+ .set({ progress: Math.round(progress * 100) })
225
+ .where(eq(videoRenders.id, renderId))
226
+ .catch(() => { /* ignore progress update failures */ });
227
+ },
228
+ });
229
+
230
+ // Read output file metadata
231
+ const stat = statSync(outputPath);
232
+ const durationSeconds = composition.durationInFrames / composition.fps;
233
+
234
+ await db.update(videoRenders)
235
+ .set({
236
+ status: 'complete',
237
+ progress: 100,
238
+ filePath: outputPath,
239
+ fileSize: stat.size,
240
+ durationSeconds,
241
+ completedAt: new Date().toISOString(),
242
+ })
243
+ .where(eq(videoRenders.id, renderId));
244
+
245
+ this.log('RENDER', `Render ${renderId} complete: ${outputPath} (${stat.size} bytes)`);
246
+ } catch (err: any) {
247
+ this.log('RENDER', `Render ${renderId} failed: ${err.message}`);
248
+
249
+ await db.update(videoRenders)
250
+ .set({
251
+ status: 'failed',
252
+ error: err.message,
253
+ completedAt: new Date().toISOString(),
254
+ })
255
+ .where(eq(videoRenders.id, renderId));
256
+ }
257
+ };
258
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * WorkflowGroupService — CRUD operations for reusable workflow groups.
3
+ * Groups are collections of workflow nodes/edges that can be inserted
4
+ * as a single collapsed node in the workflow canvas.
5
+ */
6
+
7
+ import { eq, or, isNull } from 'drizzle-orm';
8
+ import { workflowGroups } from '@assistkick/shared/db/schema.js';
9
+ import { randomUUID } from 'node:crypto';
10
+
11
+ interface WorkflowGroupServiceDeps {
12
+ getDb: () => any;
13
+ log: (tag: string, ...args: any[]) => void;
14
+ }
15
+
16
+ export interface WorkflowGroupRecord {
17
+ id: string;
18
+ name: string;
19
+ projectId: string | null;
20
+ graphData: string;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+
25
+ export class WorkflowGroupService {
26
+ private readonly getDb: () => any;
27
+ private readonly log: (tag: string, ...args: any[]) => void;
28
+
29
+ constructor({ getDb, log }: WorkflowGroupServiceDeps) {
30
+ this.getDb = getDb;
31
+ this.log = log;
32
+ }
33
+
34
+ /** List groups visible to the given scope (global + project-specific). */
35
+ list = async (projectId?: string): Promise<WorkflowGroupRecord[]> => {
36
+ const db = this.getDb();
37
+ const condition = projectId
38
+ ? or(isNull(workflowGroups.projectId), eq(workflowGroups.projectId, projectId))
39
+ : isNull(workflowGroups.projectId);
40
+
41
+ const rows = await db.select().from(workflowGroups).where(condition);
42
+ this.log('WORKFLOW_GROUPS', `Listed ${rows.length} groups`);
43
+ return rows;
44
+ };
45
+
46
+ /** Get a single group by ID. */
47
+ getById = async (id: string): Promise<WorkflowGroupRecord | null> => {
48
+ const db = this.getDb();
49
+ const [row] = await db.select().from(workflowGroups).where(eq(workflowGroups.id, id));
50
+ return row || null;
51
+ };
52
+
53
+ /** Create a new reusable group from selected nodes/edges. */
54
+ create = async (data: {
55
+ name: string;
56
+ projectId?: string | null;
57
+ graphData: string;
58
+ }): Promise<WorkflowGroupRecord> => {
59
+ const db = this.getDb();
60
+ const now = new Date().toISOString();
61
+ const id = randomUUID();
62
+
63
+ const record: WorkflowGroupRecord = {
64
+ id,
65
+ name: data.name,
66
+ projectId: data.projectId || null,
67
+ graphData: data.graphData,
68
+ createdAt: now,
69
+ updatedAt: now,
70
+ };
71
+
72
+ await db.insert(workflowGroups).values(record);
73
+ this.log('WORKFLOW_GROUPS', `Created group: ${data.name} (${id})`);
74
+ return record;
75
+ };
76
+
77
+ /** Update a group's name or graph data. */
78
+ update = async (id: string, data: {
79
+ name?: string;
80
+ graphData?: string;
81
+ }): Promise<WorkflowGroupRecord> => {
82
+ const db = this.getDb();
83
+ const existing = await this.getById(id);
84
+ if (!existing) throw new Error('Workflow group not found');
85
+
86
+ const now = new Date().toISOString();
87
+ const updates: Record<string, any> = { updatedAt: now };
88
+
89
+ if (data.name !== undefined) updates.name = data.name;
90
+ if (data.graphData !== undefined) updates.graphData = data.graphData;
91
+
92
+ await db.update(workflowGroups).set(updates).where(eq(workflowGroups.id, id));
93
+ this.log('WORKFLOW_GROUPS', `Updated group: ${existing.name} (${id})`);
94
+ return { ...existing, ...updates };
95
+ };
96
+
97
+ /** Delete a group. */
98
+ delete = async (id: string): Promise<void> => {
99
+ const db = this.getDb();
100
+ const existing = await this.getById(id);
101
+ if (!existing) throw new Error('Workflow group not found');
102
+
103
+ await db.delete(workflowGroups).where(eq(workflowGroups.id, id));
104
+ this.log('WORKFLOW_GROUPS', `Deleted group: ${existing.name} (${id})`);
105
+ };
106
+ }