circuschief 0.8.0 → 1.1.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 (155) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/agents/AgentGateway.js +2 -0
  3. package/packages/server/src/agents/adapters/GeminiAdapter.js +105 -0
  4. package/packages/server/src/agents/adapters/cliUtils.js +15 -0
  5. package/packages/server/src/agents/adapters/codexCliRunner.js +1 -8
  6. package/packages/server/src/agents/adapters/geminiCliRunner.js +183 -0
  7. package/packages/server/src/agents/adapters/geminiEventMapper.js +195 -0
  8. package/packages/server/src/api/commandButtons.js +16 -15
  9. package/packages/server/src/api/projects-commandButtons.js +6 -6
  10. package/packages/server/src/api/projects-session-create.js +109 -0
  11. package/packages/server/src/api/projects-session-defaults.js +51 -0
  12. package/packages/server/src/api/projects-session-helpers.js +47 -1
  13. package/packages/server/src/api/projects-templates.js +38 -0
  14. package/packages/server/src/api/projects.js +28 -180
  15. package/packages/server/src/api/sessions-commands.js +21 -18
  16. package/packages/server/src/api/sessions-patch.js +41 -1
  17. package/packages/server/src/db/ProviderRepository.js +4 -2
  18. package/packages/server/src/db/SessionRepository.js +1 -1
  19. package/packages/server/src/db/SessionTemplateRepository.js +23 -2
  20. package/packages/server/src/db/migrations/canvasItemsMigrations.js +109 -0
  21. package/packages/server/src/db/migrations/conversationsMigrations.js +187 -0
  22. package/packages/server/src/db/migrations/index.js +234 -6
  23. package/packages/server/src/db/migrations/kanbanMigrations.js +99 -0
  24. package/packages/server/src/db/migrations/miscMigrations.js +244 -0
  25. package/packages/server/src/db/migrations/projectsMigrations.js +130 -0
  26. package/packages/server/src/db/migrations/providerCommitAttributionMigrations.js +30 -0
  27. package/packages/server/src/db/migrations/providerMigrations.js +250 -0
  28. package/packages/server/src/db/migrations/sessionTableRecreate.js +136 -0
  29. package/packages/server/src/db/migrations/sessionsMigrations.js +300 -0
  30. package/packages/server/src/db/seedBaselineData.js +23 -1
  31. package/packages/server/src/db/session-helpers.js +26 -1
  32. package/packages/server/src/schema.sql +5 -1
  33. package/packages/server/src/services/commandButtonPrompts.js +9 -7
  34. package/packages/server/src/services/e2eSpawnCapture.js +47 -6
  35. package/packages/server/src/services/geminiSpawnHelper.js +47 -0
  36. package/packages/server/src/services/gitCommitAttribution.js +38 -8
  37. package/packages/server/src/services/gitDiff.js +107 -0
  38. package/packages/server/src/services/gitRepoUrl.js +174 -0
  39. package/packages/server/src/services/gitService.js +43 -311
  40. package/packages/server/src/services/gitWorktree.js +127 -0
  41. package/packages/server/src/services/providerTestService.js +59 -1
  42. package/packages/server/src/services/queryParamBuilder.js +33 -1
  43. package/packages/server/src/services/sessionExecution.js +4 -0
  44. package/packages/server/src/services/sessionPrompts.js +23 -1
  45. package/packages/server/src/services/sessionProvider.js +41 -1
  46. package/packages/shared/src/constants.js +1 -1
  47. package/packages/shared/src/contracts/providers.js +1 -1
  48. package/packages/shared/src/contracts/sessions.js +27 -1
  49. package/packages/shared/src/contracts/templates.js +10 -0
  50. package/packages/shared/src/types.js +7 -0
  51. package/packages/web/dist/assets/{ActiveSessionsView-B0XHqLmv.js → ActiveSessionsView-EdNxmPmZ.js} +1 -1
  52. package/packages/web/dist/assets/{AgentLogsView-DmsjUMlB.js → AgentLogsView-C2wX0JPP.js} +2 -2
  53. package/packages/web/dist/assets/ApiClient-DfbJwzpz.js +1 -0
  54. package/packages/web/dist/assets/ArchiveConfirmModal-DJERn5XO.js +1 -0
  55. package/packages/web/dist/assets/CommandButtonDetailView-CBPI8-US.js +1 -0
  56. package/packages/web/dist/assets/CommandButtonDetailView-D9zjx9ME.css +1 -0
  57. package/packages/web/dist/assets/EffortLevelSelector-PaBpUveC.js +1 -0
  58. package/packages/web/dist/assets/{GeneralSettingsView-D1nI8_zk.js → GeneralSettingsView-Dw-x83R0.js} +1 -1
  59. package/packages/web/dist/assets/{InputWithButton-CAkttyqx.js → InputWithButton-CHHcpF4I.js} +1 -1
  60. package/packages/web/dist/assets/{InterpolationHelp-BO1j9Z3_.js → InterpolationHelp-CLNPz8s8.js} +1 -1
  61. package/packages/web/dist/assets/MarkdownEditor-DYi1igfT.js +2 -0
  62. package/packages/web/dist/assets/ModelSelector-Cko_yTO5.js +1 -0
  63. package/packages/web/dist/assets/{ModelSelector-BSxKUSus.css → ModelSelector-Dtwe5xLH.css} +1 -1
  64. package/packages/web/dist/assets/{NewSessionView-BDPb-1qr.css → NewSessionView-DBl7T2Xp.css} +1 -1
  65. package/packages/web/dist/assets/NewSessionView-DwUfBg70.js +3 -0
  66. package/packages/web/dist/assets/ProjectEditView-CSbsea3U.js +1 -0
  67. package/packages/web/dist/assets/ProjectEditView-DbqTbA0q.css +1 -0
  68. package/packages/web/dist/assets/{ProjectListView-DcNyuINs.js → ProjectListView-CEc_LWZL.js} +1 -1
  69. package/packages/web/dist/assets/{ProjectNewView-B5YV62hv.js → ProjectNewView-D4U0uRlp.js} +1 -1
  70. package/packages/web/dist/assets/ProvidersView-2KCOiY6Q.css +1 -0
  71. package/packages/web/dist/assets/ProvidersView-CD1j8BOv.js +1 -0
  72. package/packages/web/dist/assets/QuickResponsesPanel-Dp39f12o.js +1 -0
  73. package/packages/web/dist/assets/QuickResponsesPanel-dk-Rj8xx.css +1 -0
  74. package/packages/web/dist/assets/ResizableTextarea-BWywIqOv.js +1 -0
  75. package/packages/web/dist/assets/ResizableTextarea-DERSH3Wz.css +1 -0
  76. package/packages/web/dist/assets/SessionCard-B6d5ijDW.js +1 -0
  77. package/packages/web/dist/assets/SessionDetailView-DWbXdx7A.js +36 -0
  78. package/packages/web/dist/assets/SessionDetailView-ULeIkWS0.css +1 -0
  79. package/packages/web/dist/assets/{SessionFormOptions-B6AxyREh.js → SessionFormOptions-Dz9ik4Fo.js} +1 -1
  80. package/packages/web/dist/assets/{SessionListView-B5_6gW49.css → SessionListView-3-xx6EVs.css} +1 -1
  81. package/packages/web/dist/assets/SessionListView-C129buBe.js +1 -0
  82. package/packages/web/dist/assets/{SessionLogStream-LlZ3z_Xj.js → SessionLogStream-BvXUNNBZ.js} +6 -6
  83. package/packages/web/dist/assets/{SettingsView-CTGiGvR2.js → SettingsView-DW1NvpX_.js} +1 -1
  84. package/packages/web/dist/assets/SlashCommandWizard-DleYBxrE.js +1 -0
  85. package/packages/web/dist/assets/{SummarySettingsView-BR2ZjEa3.js → SummarySettingsView-CLUfcWvf.js} +1 -1
  86. package/packages/web/dist/assets/TemplateDetailView-B5NI2oTR.css +1 -0
  87. package/packages/web/dist/assets/TemplateDetailView-Cukb205e.js +1 -0
  88. package/packages/web/dist/assets/{commandButtons-BfqR-fqq.js → commandButtons-DejH0rVN.js} +1 -1
  89. package/packages/web/dist/assets/index-BD7Y3rBE.js +3 -0
  90. package/packages/web/dist/assets/{index-BY174HVJ.css → index-Bd20AzX1.css} +1 -1
  91. package/packages/web/dist/assets/index-BgJiarKe.js +1 -0
  92. package/packages/web/dist/assets/index-Bk32fSSG.js +1 -0
  93. package/packages/web/dist/assets/index-BkA6pF2Z.js +1 -0
  94. package/packages/web/dist/assets/index-Cltr-Ldt.js +7 -0
  95. package/packages/web/dist/assets/index-Co-46Tp3.js +1 -0
  96. package/packages/web/dist/assets/index-Cpykk857.js +1 -0
  97. package/packages/web/dist/assets/index-CtABl0D1.js +1 -0
  98. package/packages/web/dist/assets/index-Cuqk5m9S.js +1 -0
  99. package/packages/web/dist/assets/{index-fK8FIZgP.js → index-CvXApbVC.js} +15 -15
  100. package/packages/web/dist/assets/index-D2gN-xEH.js +1 -0
  101. package/packages/web/dist/assets/index-Dd3WpmyQ.js +1 -0
  102. package/packages/web/dist/assets/index-Dk6--9rj.js +1 -0
  103. package/packages/web/dist/assets/{index-DgkC10TW.js → index-MZf7MlPX.js} +3 -3
  104. package/packages/web/dist/assets/{index-DtfUt785.js → index-NShCcwfj.js} +1 -1
  105. package/packages/web/dist/assets/index-hA3VEuSq.js +1 -0
  106. package/packages/web/dist/assets/index-p0mp3nca.js +1 -0
  107. package/packages/web/dist/assets/index-qntNa5r_.js +1 -0
  108. package/packages/web/dist/assets/index-qq9ceNSK.js +1 -0
  109. package/packages/web/dist/assets/projectDefaults-D9xkp2XR.js +1 -0
  110. package/packages/web/dist/assets/{projects-DXYQNJIi.js → projects-BvLADGKx.js} +1 -1
  111. package/packages/web/dist/assets/{providers-1bnH-exJ.js → providers-DZ-fOa4G.js} +1 -1
  112. package/packages/web/dist/assets/{sessions-6zGUlFrt.js → sessions-DETEyjPI.js} +1 -1
  113. package/packages/web/dist/assets/{settings-MbfRir0d.js → settings-TWfbahn5.js} +1 -1
  114. package/packages/web/dist/index.html +2 -2
  115. package/packages/web/dist/assets/ApiClient-C3ztI9s9.js +0 -1
  116. package/packages/web/dist/assets/ArchiveConfirmModal-BlCyn5Vt.js +0 -1
  117. package/packages/web/dist/assets/CommandButtonDetailView-CdSCPp78.js +0 -1
  118. package/packages/web/dist/assets/CommandButtonDetailView-DBm3rzhw.css +0 -1
  119. package/packages/web/dist/assets/EffortLevelSelector-hc2MNKg6.js +0 -1
  120. package/packages/web/dist/assets/MarkdownEditor-ucRAP_UM.js +0 -2
  121. package/packages/web/dist/assets/ModelSelector-CwTz8ZWO.js +0 -1
  122. package/packages/web/dist/assets/NewSessionView-BsDrp8mj.js +0 -3
  123. package/packages/web/dist/assets/ProjectEditView-CwTOeSun.js +0 -1
  124. package/packages/web/dist/assets/ProjectEditView-J15mcsWz.css +0 -1
  125. package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +0 -1
  126. package/packages/web/dist/assets/ProvidersView-nY9GnDdO.js +0 -1
  127. package/packages/web/dist/assets/QuickResponseSettings-B352c75l.css +0 -1
  128. package/packages/web/dist/assets/QuickResponseSettings-BQwQXuL7.js +0 -1
  129. package/packages/web/dist/assets/QuickResponsesPanel-BlFDvnZ2.css +0 -1
  130. package/packages/web/dist/assets/QuickResponsesPanel-BzSYcCSP.js +0 -1
  131. package/packages/web/dist/assets/ResizableTextarea-B3YIdIXv.js +0 -1
  132. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +0 -1
  133. package/packages/web/dist/assets/SessionCard-CjE1tXiT.js +0 -1
  134. package/packages/web/dist/assets/SessionDetailView-3cPZrbS3.js +0 -36
  135. package/packages/web/dist/assets/SessionDetailView-CZRZMrfM.css +0 -1
  136. package/packages/web/dist/assets/SessionListView-CLXBfLcq.js +0 -1
  137. package/packages/web/dist/assets/SlashCommandWizard-Cy04d7-o.js +0 -1
  138. package/packages/web/dist/assets/TemplateDetailView-DH6Oswsp.js +0 -1
  139. package/packages/web/dist/assets/TemplateDetailView-DT2m06W7.css +0 -1
  140. package/packages/web/dist/assets/index-1zziPL6l.js +0 -1
  141. package/packages/web/dist/assets/index-7kzHPxSF.js +0 -1
  142. package/packages/web/dist/assets/index-B0N_obMc.js +0 -1
  143. package/packages/web/dist/assets/index-BNk_gdfI.js +0 -1
  144. package/packages/web/dist/assets/index-CSqaAH-0.js +0 -1
  145. package/packages/web/dist/assets/index-C_q4WlK8.js +0 -1
  146. package/packages/web/dist/assets/index-D1wpU4y0.js +0 -7
  147. package/packages/web/dist/assets/index-D5zCA8sD.js +0 -1
  148. package/packages/web/dist/assets/index-DGR8ELWY.js +0 -1
  149. package/packages/web/dist/assets/index-DHga8pXo.js +0 -1
  150. package/packages/web/dist/assets/index-DSby02Wl.js +0 -1
  151. package/packages/web/dist/assets/index-DqjXJTVI.js +0 -1
  152. package/packages/web/dist/assets/index-_4S2uLDI.js +0 -1
  153. package/packages/web/dist/assets/index-gmiZeFXN.js +0 -1
  154. package/packages/web/dist/assets/index-irD539ZM.js +0 -3
  155. package/packages/web/dist/assets/index-yq-E1Y00.js +0 -1
@@ -4,11 +4,11 @@ import { CreateCommandButtonRequest, UpdateCommandButtonRequest } from '../../..
4
4
 
5
5
  // Error message constants
6
6
  const ERR_PROJECT_NOT_FOUND = 'Project not found';
7
- const ERR_BUTTON_NOT_FOUND = 'Command button not found';
7
+ const ERR_BUTTON_NOT_FOUND = 'Circus Command not found';
8
8
 
9
9
  const router = Router({ mergeParams: true });
10
10
 
11
- // GET /api/projects/:id/command-buttons - List all command buttons for project
11
+ // GET /api/projects/:id/circus-commands - List all command buttons for project
12
12
  router.get('/', (req, res) => {
13
13
  const project = projects.getById(req.params.id);
14
14
  if (!project) {
@@ -19,7 +19,7 @@ router.get('/', (req, res) => {
19
19
  res.json(buttons);
20
20
  });
21
21
 
22
- // POST /api/projects/:id/command-buttons - Create new command button
22
+ // POST /api/projects/:id/circus-commands - Create new command button
23
23
  router.post('/', (req, res) => {
24
24
  const project = projects.getById(req.params.id);
25
25
  if (!project) {
@@ -42,7 +42,7 @@ router.post('/', (req, res) => {
42
42
  res.status(201).json(button);
43
43
  });
44
44
 
45
- // GET /api/projects/:id/command-buttons/:buttonId - Get single button
45
+ // GET /api/projects/:id/circus-commands/:buttonId - Get single button
46
46
  router.get('/:buttonId', (req, res) => {
47
47
  const project = projects.getById(req.params.id);
48
48
  if (!project) {
@@ -56,7 +56,7 @@ router.get('/:buttonId', (req, res) => {
56
56
  res.json(button);
57
57
  });
58
58
 
59
- // PATCH /api/projects/:id/command-buttons/:buttonId - Update button
59
+ // PATCH /api/projects/:id/circus-commands/:buttonId - Update button
60
60
  router.patch('/:buttonId', (req, res) => {
61
61
  const project = projects.getById(req.params.id);
62
62
  if (!project) {
@@ -77,7 +77,7 @@ router.patch('/:buttonId', (req, res) => {
77
77
  res.json(updated);
78
78
  });
79
79
 
80
- // DELETE /api/projects/:id/command-buttons/:buttonId - Delete button
80
+ // DELETE /api/projects/:id/circus-commands/:buttonId - Delete button
81
81
  router.delete('/:buttonId', (req, res) => {
82
82
  const project = projects.getById(req.params.id);
83
83
  if (!project) {
@@ -0,0 +1,109 @@
1
+ import { sessions, projectDefaults } from '../database.js';
2
+ import { ProjectDefaultsRepository } from '../db/ProjectDefaultsRepository.js';
3
+ import { broadcastToProject } from '../websocket.js';
4
+ import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
5
+ import {
6
+ generateInitialName,
7
+ prepareSessionConfig,
8
+ applyTemplateOverrides,
9
+ resolveNextTemplateId,
10
+ buildSchedulingUpdate,
11
+ setupAndStartSession,
12
+ } from './projects-session-helpers.js';
13
+ import { validateGitSettings } from './projects-helpers.js';
14
+
15
+ /**
16
+ * Validate and prepare the session configuration from the request body.
17
+ * Returns { config, nextTemplateId } on success, or { error, status } on failure.
18
+ */
19
+ export async function validateAndPrepareSessionConfig(reqBody, reqFiles, projectId, project) {
20
+ const projectDefs = projectDefaults.getByProjectId(projectId);
21
+ const systemDefaults = ProjectDefaultsRepository.getSystemDefaults();
22
+ const config = prepareSessionConfig(reqBody, projectDefs, systemDefaults);
23
+ config.files = reqFiles || [];
24
+
25
+ if (!config.prompt) {
26
+ return { error: 'Prompt is required', status: 400 };
27
+ }
28
+
29
+ if (config.schedulingError) {
30
+ return { error: config.schedulingError, status: 400 };
31
+ }
32
+
33
+ if (config.parentSessionId) {
34
+ const parentSession = sessions.getById(config.parentSessionId);
35
+ if (!parentSession) {
36
+ return { error: 'Parent session not found', status: 404 };
37
+ }
38
+ if (parentSession.projectId !== projectId) {
39
+ return { error: 'Parent session does not belong to this project', status: 400 };
40
+ }
41
+ }
42
+
43
+ // Apply template overrides and resolve nextTemplateId
44
+ applyTemplateOverrides(config);
45
+ const { nextTemplateId, error: nextTemplateError } = resolveNextTemplateId(reqBody, config.nextTemplateId || null);
46
+ if (nextTemplateError) {
47
+ return { error: nextTemplateError, status: 400 };
48
+ }
49
+ config.nextTemplateId = nextTemplateId;
50
+
51
+ // Validate git settings for git repos
52
+ const { config: updatedConfig, error: gitError } = await validateGitSettings(config, project);
53
+ if (gitError) {
54
+ return { error: gitError, status: 400 };
55
+ }
56
+ Object.assign(config, updatedConfig);
57
+
58
+ return { config, nextTemplateId };
59
+ }
60
+
61
+ /**
62
+ * Create the session row and apply any post-create updates.
63
+ * Returns the created session (already persisted in DB).
64
+ */
65
+ export function createSessionRow(projectId, config, nextTemplateId, initialStatus) {
66
+ const sessionName = config.name || generateInitialName(config.prompt);
67
+ const session = sessions.create(projectId, sessionName, config.prompt, {
68
+ mode: config.mode,
69
+ thinkingEnabled: config.thinkingEnabled,
70
+ gitBranch: config.gitBranch,
71
+ parentSessionId: config.parentSessionId,
72
+ status: initialStatus,
73
+ model: config.model,
74
+ providerId: config.providerId,
75
+ effortLevel: config.effortLevel,
76
+ agentType: config.agentType,
77
+ });
78
+
79
+ const postCreateUpdate = {
80
+ ...(nextTemplateId ? { nextTemplateId } : {}),
81
+ ...buildSchedulingUpdate(config, initialStatus),
82
+ };
83
+ if (Object.keys(postCreateUpdate).length > 0) {
84
+ sessions.update(session.id, postCreateUpdate);
85
+ }
86
+ return session;
87
+ }
88
+
89
+ /**
90
+ * Run setupAndStartSession and translate any failure into an error response,
91
+ * marking the session as errored and broadcasting the update.
92
+ */
93
+ export async function startSessionOrFail(req, res, { session, config, project }) {
94
+ try {
95
+ const { updatedSession } = await setupAndStartSession({
96
+ session, config, project, projectId: req.params.id, files: config.files,
97
+ });
98
+ return res.status(201).json(updatedSession);
99
+ } catch (error) {
100
+ console.error('Git setup error:', error);
101
+ const updatedSession = sessions.update(session.id, { status: 'error', error: error.message });
102
+ broadcastToProject(req.params.id, WS_MESSAGE_TYPES.SESSION_UPDATED, {
103
+ projectId: req.params.id,
104
+ sessionId: session.id,
105
+ session: updatedSession,
106
+ });
107
+ return res.status(500).json({ error: `Git setup failed: ${error.message}` });
108
+ }
109
+ }
@@ -0,0 +1,51 @@
1
+ import { Router } from 'express';
2
+ import { projects, projectDefaults } from '../database.js';
3
+ import { ProjectSessionDefaultsRequest } from '../../../shared/src/contracts/projects.js';
4
+
5
+ const ERR_PROJECT_NOT_FOUND = 'Project not found';
6
+
7
+ const router = Router({ mergeParams: true });
8
+
9
+ // GET /api/projects/:id/session-defaults - Get session defaults for project
10
+ router.get('/', (req, res) => {
11
+ const project = projects.getById(req.params.id);
12
+ if (!project) {
13
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
14
+ }
15
+
16
+ const defaults = projectDefaults.getByProjectId(req.params.id);
17
+ if (!defaults) {
18
+ return res.json(null);
19
+ }
20
+
21
+ res.json(defaults);
22
+ });
23
+
24
+ // POST /api/projects/:id/session-defaults - Update/create session defaults for project
25
+ router.post('/', (req, res) => {
26
+ const project = projects.getById(req.params.id);
27
+ if (!project) {
28
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
29
+ }
30
+
31
+ const result = ProjectSessionDefaultsRequest.safeParse(req.body);
32
+ if (!result.success) {
33
+ return res.status(400).json({ error: result.error.issues[0].message });
34
+ }
35
+
36
+ const updated = projectDefaults.upsert(req.params.id, result.data);
37
+ res.status(200).json(updated);
38
+ });
39
+
40
+ // DELETE /api/projects/:id/session-defaults - Reset session defaults for project
41
+ router.delete('/', (req, res) => {
42
+ const project = projects.getById(req.params.id);
43
+ if (!project) {
44
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
45
+ }
46
+
47
+ projectDefaults.resetToDefaults(req.params.id);
48
+ res.json({ message: 'Session defaults reset to system defaults' });
49
+ });
50
+
51
+ export default router;
@@ -6,6 +6,9 @@ import { executeHookAsync } from '../services/hookService.js';
6
6
  import { broadcastToProject } from '../websocket.js';
7
7
  import { WS_MESSAGE_TYPES, DEFAULT_RESCHEDULE_DELAY_MINUTES } from '../../../shared/src/index.js';
8
8
 
9
+ const SCHEDULED_AT_FORMAT_MESSAGE = 'scheduledAt must be a valid ISO 8601 date-time string with a timezone, for example "2026-06-12T14:00:00Z".';
10
+ const ISO_8601_DATE_TIME_WITH_TIMEZONE = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d+)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
11
+
9
12
  /**
10
13
  * Generate an initial session name from the prompt
11
14
  * This will be replaced by a better name when the summary is generated
@@ -83,14 +86,57 @@ export function resolveThinkingEnabled(body, projectDefs, systemDefaults) {
83
86
  return systemDefaults.thinkingEnabled;
84
87
  }
85
88
 
89
+ function hasValidDateParts(year, month, day) {
90
+ const parsed = new Date(Date.UTC(year, month - 1, day));
91
+ return parsed.getUTCFullYear() === year
92
+ && parsed.getUTCMonth() === month - 1
93
+ && parsed.getUTCDate() === day;
94
+ }
95
+
96
+ /**
97
+ * Parse scheduledAt from the public API contract.
98
+ * @param {*} value - Raw scheduledAt value
99
+ * @returns {{ value: number|undefined, error: string|null }}
100
+ */
101
+ export function parseScheduledAt(value) {
102
+ if (value === undefined || value === null || value === '') {
103
+ return { value: undefined, error: null };
104
+ }
105
+
106
+ if (typeof value !== 'string') {
107
+ return { value: undefined, error: SCHEDULED_AT_FORMAT_MESSAGE };
108
+ }
109
+
110
+ const match = ISO_8601_DATE_TIME_WITH_TIMEZONE.exec(value);
111
+ if (!match) {
112
+ return { value: undefined, error: SCHEDULED_AT_FORMAT_MESSAGE };
113
+ }
114
+
115
+ const year = Number(match[1]);
116
+ const month = Number(match[2]);
117
+ const day = Number(match[3]);
118
+ if (!hasValidDateParts(year, month, day)) {
119
+ return { value: undefined, error: SCHEDULED_AT_FORMAT_MESSAGE };
120
+ }
121
+
122
+ const timestamp = Date.parse(value);
123
+ if (!Number.isFinite(timestamp)) {
124
+ return { value: undefined, error: SCHEDULED_AT_FORMAT_MESSAGE };
125
+ }
126
+
127
+ return { value: timestamp, error: null };
128
+ }
129
+
86
130
  /**
87
131
  * Parse scheduling fields from request body.
88
132
  * @param {object} body - Request body
89
133
  * @returns {object} Scheduling configuration
90
134
  */
91
135
  export function parseSchedulingConfig(body) {
136
+ const scheduledAt = parseScheduledAt(body.scheduledAt);
92
137
  return {
93
- scheduledAt: body.scheduledAt ? parseInt(body.scheduledAt, 10) : undefined,
138
+ scheduledAt: scheduledAt.value,
139
+ schedulingError: scheduledAt.error,
94
140
  autoRescheduleEnabled: body.autoRescheduleEnabled === true || body.autoRescheduleEnabled === 'true',
95
141
  rescheduleDelayMinutes: body.rescheduleDelayMinutes ? parseInt(body.rescheduleDelayMinutes, 10) : DEFAULT_RESCHEDULE_DELAY_MINUTES,
96
142
  rescheduleOnTokenLimit: body.rescheduleOnTokenLimit !== false && body.rescheduleOnTokenLimit !== 'false',
@@ -0,0 +1,38 @@
1
+ import { Router } from 'express';
2
+ import { projects, sessionTemplates } from '../database.js';
3
+ import { CreateSessionTemplateRequest } from '../../../shared/src/contracts/templates.js';
4
+
5
+ const ERR_PROJECT_NOT_FOUND = 'Project not found';
6
+ const router = Router({ mergeParams: true });
7
+
8
+ // GET - List available templates for project (project + global)
9
+ router.get('/', (req, res) => {
10
+ const project = projects.getById(req.params.id);
11
+ if (!project) {
12
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
13
+ }
14
+
15
+ const available = sessionTemplates.getAvailableForProject(req.params.id);
16
+ res.json(available);
17
+ });
18
+
19
+ // POST - Create project template
20
+ router.post('/', (req, res) => {
21
+ const project = projects.getById(req.params.id);
22
+ if (!project) {
23
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
24
+ }
25
+
26
+ const result = CreateSessionTemplateRequest.safeParse(req.body);
27
+ if (!result.success) {
28
+ return res.status(400).json({ error: result.error.issues[0].message });
29
+ }
30
+
31
+ const template = sessionTemplates.create({
32
+ projectId: req.params.id,
33
+ ...result.data,
34
+ });
35
+ res.status(201).json(template);
36
+ });
37
+
38
+ export default router;
@@ -1,26 +1,18 @@
1
1
  import { Router } from 'express';
2
- import { projects, sessions, sessionTemplates, projectDefaults, commandRuns } from '../database.js';
2
+ import { projects, sessions, commandRuns } from '../database.js';
3
3
  import { commandRunner } from '../services/commandRunner.js';
4
- import { CreateProjectRequest, UpdateProjectRequest, ProjectSessionDefaultsRequest } from '../../../shared/src/contracts/projects.js';
5
- import { ProjectDefaultsRepository } from '../db/ProjectDefaultsRepository.js';
6
- import { CreateSessionTemplateRequest } from '../../../shared/src/contracts/templates.js';
7
- import { broadcastToProject } from '../websocket.js';
4
+ import { CreateProjectRequest, UpdateProjectRequest } from '../../../shared/src/contracts/projects.js';
8
5
  import projectCommandButtonsRouter from './projects-commandButtons.js';
9
- import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
6
+ import projectSessionDefaultsRouter from './projects-session-defaults.js';
7
+ import projectTemplatesRouter from './projects-templates.js';
10
8
  import { handleUploadError, uploadMiddleware } from '../middleware/upload.js';
11
- import {
12
- generateInitialName,
13
- prepareSessionConfig,
14
- applyTemplateOverrides,
15
- resolveNextTemplateId,
16
- determineInitialStatus,
17
- buildSchedulingUpdate,
18
- setupAndStartSession,
19
- } from './projects-session-helpers.js';
20
- import { validateGitSettings, buildRunsBySession } from './projects-helpers.js';
9
+ import { determineInitialStatus } from './projects-session-helpers.js';
10
+ import { buildRunsBySession } from './projects-helpers.js';
21
11
  import { resolveAgentTypeFromModel } from '../services/sessionProvider.js';
22
12
  import { access, constants } from 'fs/promises';
23
13
  import { dirname, isAbsolute } from 'path';
14
+ import { getRepositoryUrl } from '../services/gitService.js';
15
+ import { validateAndPrepareSessionConfig, createSessionRow, startSessionOrFail } from './projects-session-create.js';
24
16
 
25
17
  // Error message constants
26
18
  const ERR_PROJECT_NOT_FOUND = 'Project not found';
@@ -65,17 +57,31 @@ router.post('/', async (req, res) => {
65
57
  return res.status(400).json({ error: result.error.issues[0].message });
66
58
  }
67
59
 
68
- const { name, workingDirectory, systemPrompt, onSessionCreated, onSessionDeleted, worktreePath, kanbanEnabled } = result.data;
60
+ const { name, workingDirectory, systemPrompt, onSessionCreated, onSessionDeleted, worktreePath, kanbanEnabled, repoUrl } = result.data;
69
61
 
70
62
  const pathError = await validateWorktreePath(worktreePath);
71
63
  if (pathError) {
72
64
  return res.status(400).json({ error: pathError });
73
65
  }
74
66
 
67
+ // Resolve repoUrl:
68
+ // - string: use as-is (explicitly provided)
69
+ // - null: suppress detection (explicitly sent as null)
70
+ // - undefined: auto-detect from git remote
71
+ let resolvedRepoUrl = repoUrl;
72
+ if (resolvedRepoUrl === undefined) {
73
+ try {
74
+ resolvedRepoUrl = await getRepositoryUrl(workingDirectory);
75
+ } catch {
76
+ resolvedRepoUrl = null;
77
+ }
78
+ }
79
+
75
80
  const createOptions = {
76
81
  onSessionCreated: onSessionCreated || null,
77
82
  onSessionDeleted: onSessionDeleted || null,
78
83
  worktreePath: worktreePath || null,
84
+ repoUrl: resolvedRepoUrl,
79
85
  };
80
86
  if (kanbanEnabled !== undefined) {
81
87
  createOptions.kanbanEnabled = kanbanEnabled;
@@ -198,98 +204,6 @@ router.get('/:id/sessions', (req, res) => {
198
204
  }
199
205
  });
200
206
 
201
- /**
202
- * Validate and prepare the session configuration from the request body.
203
- * Returns { config, nextTemplateId } on success, or { error, status } on failure.
204
- */
205
- async function validateAndPrepareSessionConfig(reqBody, reqFiles, projectId, project) {
206
- const projectDefs = projectDefaults.getByProjectId(projectId);
207
- const systemDefaults = ProjectDefaultsRepository.getSystemDefaults();
208
- const config = prepareSessionConfig(reqBody, projectDefs, systemDefaults);
209
- config.files = reqFiles || [];
210
-
211
- if (!config.prompt) {
212
- return { error: 'Prompt is required', status: 400 };
213
- }
214
-
215
- if (config.parentSessionId) {
216
- const parentSession = sessions.getById(config.parentSessionId);
217
- if (!parentSession) {
218
- return { error: 'Parent session not found', status: 404 };
219
- }
220
- if (parentSession.projectId !== projectId) {
221
- return { error: 'Parent session does not belong to this project', status: 400 };
222
- }
223
- }
224
-
225
- // Apply template overrides and resolve nextTemplateId
226
- applyTemplateOverrides(config);
227
- const { nextTemplateId, error: nextTemplateError } = resolveNextTemplateId(reqBody, config.nextTemplateId || null);
228
- if (nextTemplateError) {
229
- return { error: nextTemplateError, status: 400 };
230
- }
231
- config.nextTemplateId = nextTemplateId;
232
-
233
- // Validate git settings for git repos
234
- const { config: updatedConfig, error: gitError } = await validateGitSettings(config, project);
235
- if (gitError) {
236
- return { error: gitError, status: 400 };
237
- }
238
- Object.assign(config, updatedConfig);
239
-
240
- return { config, nextTemplateId };
241
- }
242
-
243
- /**
244
- * Create the session row and apply any post-create updates.
245
- * Returns the created session (already persisted in DB).
246
- */
247
- function createSessionRow(projectId, config, nextTemplateId, initialStatus) {
248
- const sessionName = config.name || generateInitialName(config.prompt);
249
- const session = sessions.create(projectId, sessionName, config.prompt, {
250
- mode: config.mode,
251
- thinkingEnabled: config.thinkingEnabled,
252
- gitBranch: config.gitBranch,
253
- parentSessionId: config.parentSessionId,
254
- status: initialStatus,
255
- model: config.model,
256
- providerId: config.providerId,
257
- effortLevel: config.effortLevel,
258
- agentType: config.agentType,
259
- });
260
-
261
- const postCreateUpdate = {
262
- ...(nextTemplateId ? { nextTemplateId } : {}),
263
- ...buildSchedulingUpdate(config, initialStatus),
264
- };
265
- if (Object.keys(postCreateUpdate).length > 0) {
266
- sessions.update(session.id, postCreateUpdate);
267
- }
268
- return session;
269
- }
270
-
271
- /**
272
- * Run setupAndStartSession and translate any failure into an error response,
273
- * marking the session as errored and broadcasting the update.
274
- */
275
- async function startSessionOrFail(req, res, { session, config, project }) {
276
- try {
277
- const { updatedSession } = await setupAndStartSession({
278
- session, config, project, projectId: req.params.id, files: config.files,
279
- });
280
- return res.status(201).json(updatedSession);
281
- } catch (error) {
282
- console.error('Git setup error:', error);
283
- const updatedSession = sessions.update(session.id, { status: 'error', error: error.message });
284
- broadcastToProject(req.params.id, WS_MESSAGE_TYPES.SESSION_UPDATED, {
285
- projectId: req.params.id,
286
- sessionId: session.id,
287
- session: updatedSession,
288
- });
289
- return res.status(500).json({ error: `Git setup failed: ${error.message}` });
290
- }
291
- }
292
-
293
207
  // POST /api/projects/:id/sessions - Create session
294
208
  // Supports both JSON and multipart/form-data (for file attachments)
295
209
  router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
@@ -335,79 +249,13 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
335
249
  }
336
250
  });
337
251
 
338
- // GET /api/projects/:id/templates - List available templates for project (project + global)
339
- router.get('/:id/templates', (req, res) => {
340
- const project = projects.getById(req.params.id);
341
- if (!project) {
342
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
343
- }
344
-
345
- const available = sessionTemplates.getAvailableForProject(req.params.id);
346
- res.json(available);
347
- });
348
-
349
- // POST /api/projects/:id/templates - Create project template
350
- router.post('/:id/templates', (req, res) => {
351
- const project = projects.getById(req.params.id);
352
- if (!project) {
353
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
354
- }
355
-
356
- const result = CreateSessionTemplateRequest.safeParse(req.body);
357
- if (!result.success) {
358
- return res.status(400).json({ error: result.error.issues[0].message });
359
- }
360
-
361
- const template = sessionTemplates.create({
362
- projectId: req.params.id,
363
- ...result.data,
364
- });
365
- res.status(201).json(template);
366
- });
252
+ // Template routes are mounted as a sub-router
253
+ router.use('/:id/templates', projectTemplatesRouter);
367
254
 
368
255
  // Command button routes are mounted as a sub-router
369
- router.use('/:id/command-buttons', projectCommandButtonsRouter);
370
-
371
- // GET /api/projects/:id/session-defaults - Get session defaults for project
372
- router.get('/:id/session-defaults', (req, res) => {
373
- const project = projects.getById(req.params.id);
374
- if (!project) {
375
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
376
- }
377
-
378
- const defaults = projectDefaults.getByProjectId(req.params.id);
379
- if (!defaults) {
380
- return res.json(null);
381
- }
256
+ router.use('/:id/circus-commands', projectCommandButtonsRouter);
382
257
 
383
- res.json(defaults);
384
- });
385
-
386
- // POST /api/projects/:id/session-defaults - Update/create session defaults for project
387
- router.post('/:id/session-defaults', (req, res) => {
388
- const project = projects.getById(req.params.id);
389
- if (!project) {
390
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
391
- }
392
-
393
- const result = ProjectSessionDefaultsRequest.safeParse(req.body);
394
- if (!result.success) {
395
- return res.status(400).json({ error: result.error.issues[0].message });
396
- }
397
-
398
- const updated = projectDefaults.upsert(req.params.id, result.data);
399
- res.status(200).json(updated);
400
- });
401
-
402
- // DELETE /api/projects/:id/session-defaults - Reset session defaults for project
403
- router.delete('/:id/session-defaults', (req, res) => {
404
- const project = projects.getById(req.params.id);
405
- if (!project) {
406
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
407
- }
408
-
409
- projectDefaults.resetToDefaults(req.params.id);
410
- res.json({ message: 'Session defaults reset to system defaults' });
411
- });
258
+ // Session defaults routes are mounted as a sub-router
259
+ router.use('/:id/session-defaults', projectSessionDefaultsRouter);
412
260
 
413
261
  export default router;
@@ -6,6 +6,9 @@ import { requireRootSessionAndProject } from '../middleware/sessionLookup.js';
6
6
  import { commandRunner } from '../services/commandRunner.js';
7
7
  import { databaseManager } from '../db/DatabaseManager.js';
8
8
 
9
+ // Error message constants
10
+ const ERR_BUTTON_NOT_FOUND = 'Circus Command not found';
11
+
9
12
  const router = Router();
10
13
 
11
14
  /**
@@ -46,13 +49,13 @@ function broadcastCommandError(ctx, errorMessage) {
46
49
  broadcastToProject(projectId, WS_MESSAGE_TYPES.COMMAND_RUN_ERROR, { projectId, sessionId, runId, buttonId, error: errorMessage });
47
50
  }
48
51
 
49
- // GET /api/sessions/:id/command-buttons - List command buttons for the workflow project
50
- router.get('/:id/command-buttons', requireRootSessionAndProject, (req, res) => {
52
+ // GET /api/sessions/:id/circus-commands - List command buttons for the workflow project
53
+ router.get('/:id/circus-commands', requireRootSessionAndProject, (req, res) => {
51
54
  res.json(commandButtons.getByProjectId(req.rootSession_.projectId));
52
55
  });
53
56
 
54
- // POST /api/sessions/:id/command-buttons/:buttonId/run - Execute button command
55
- router.post('/:id/command-buttons/:buttonId/run', requireRootSessionAndProject, (req, res) => {
57
+ // POST /api/sessions/:id/circus-commands/:buttonId/run - Execute button command
58
+ router.post('/:id/circus-commands/:buttonId/run', requireRootSessionAndProject, (req, res) => {
56
59
  const sessionId = req.rootSessionId;
57
60
  const buttonId = req.params.buttonId;
58
61
 
@@ -60,10 +63,10 @@ router.post('/:id/command-buttons/:buttonId/run', requireRootSessionAndProject,
60
63
 
61
64
  const button = commandButtons.getById(buttonId);
62
65
  if (!button) {
63
- return res.status(404).json({ error: 'Command button not found' });
66
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
64
67
  }
65
68
  if (button.projectId !== req.rootSession_.projectId) {
66
- return res.status(404).json({ error: 'Command button not found' });
69
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
67
70
  }
68
71
 
69
72
  // Generate run ID
@@ -105,16 +108,16 @@ router.post('/:id/command-buttons/:buttonId/run', requireRootSessionAndProject,
105
108
  })();
106
109
  });
107
110
 
108
- // GET /api/sessions/:id/command-buttons/runs - Get active runs for session
109
- router.get('/:id/command-buttons/runs', requireRootSessionAndProject, (req, res) => {
111
+ // GET /api/sessions/:id/circus-commands/runs - Get active runs for session
112
+ router.get('/:id/circus-commands/runs', requireRootSessionAndProject, (req, res) => {
110
113
  const sessionId = req.rootSessionId;
111
114
 
112
115
  const activeRuns = commandRunner.getRunsBySession(sessionId);
113
116
  res.json(activeRuns);
114
117
  });
115
118
 
116
- // GET /api/sessions/:id/command-buttons/runs/:runId - Get single run by ID
117
- router.get('/:id/command-buttons/runs/:runId', requireRootSessionAndProject, (req, res) => {
119
+ // GET /api/sessions/:id/circus-commands/runs/:runId - Get single run by ID
120
+ router.get('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, (req, res) => {
118
121
  const { runId } = req.params;
119
122
  const sessionId = req.rootSessionId;
120
123
 
@@ -144,8 +147,8 @@ router.get('/:id/command-buttons/runs/:runId', requireRootSessionAndProject, (re
144
147
  });
145
148
  });
146
149
 
147
- // DELETE /api/sessions/:id/command-buttons/runs/:runId - Delete a command run record
148
- router.delete('/:id/command-buttons/runs/:runId', requireRootSessionAndProject, (req, res) => {
150
+ // DELETE /api/sessions/:id/circus-commands/runs/:runId - Delete a command run record
151
+ router.delete('/:id/circus-commands/runs/:runId', requireRootSessionAndProject, (req, res) => {
149
152
  const sessionId = req.rootSessionId;
150
153
  const { runId } = req.params;
151
154
 
@@ -178,17 +181,17 @@ router.delete('/:id/command-buttons/runs/:runId', requireRootSessionAndProject,
178
181
  res.status(204).send();
179
182
  });
180
183
 
181
- // DELETE /api/sessions/:id/command-buttons/:buttonId/runs/all - Delete all runs for a button in a session
182
- router.delete('/:id/command-buttons/:buttonId/runs/all', requireRootSessionAndProject, (req, res) => {
184
+ // DELETE /api/sessions/:id/circus-commands/:buttonId/runs/all - Delete all runs for a button in a session
185
+ router.delete('/:id/circus-commands/:buttonId/runs/all', requireRootSessionAndProject, (req, res) => {
183
186
  const sessionId = req.rootSessionId;
184
187
  const { buttonId } = req.params;
185
188
 
186
189
  const button = commandButtons.getById(buttonId);
187
190
  if (!button) {
188
- return res.status(404).json({ error: 'Command button not found' });
191
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
189
192
  }
190
193
  if (button.projectId !== req.rootSession_.projectId) {
191
- return res.status(404).json({ error: 'Command button not found' });
194
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
192
195
  }
193
196
 
194
197
  const { deletedRuns } = commandRuns.deleteByButtonAndSession(buttonId, sessionId);
@@ -213,8 +216,8 @@ router.delete('/:id/command-buttons/:buttonId/runs/all', requireRootSessionAndPr
213
216
  res.status(204).send();
214
217
  });
215
218
 
216
- // POST /api/sessions/:id/command-buttons/runs/:runId/kill - Kill running command
217
- router.post('/:id/command-buttons/runs/:runId/kill', requireRootSessionAndProject, (req, res) => {
219
+ // POST /api/sessions/:id/circus-commands/runs/:runId/kill - Kill running command
220
+ router.post('/:id/circus-commands/runs/:runId/kill', requireRootSessionAndProject, (req, res) => {
218
221
  const sessionId = req.rootSessionId;
219
222
  const runId = req.params.runId;
220
223