@loicngr/kobo 1.11.16 → 1.11.18

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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +13 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +8 -7
  4. package/dist/server/db/migrations.js +57 -0
  5. package/dist/server/db/schema.js +10 -39
  6. package/dist/server/index.js +55 -7
  7. package/dist/server/routes/git.js +8 -3
  8. package/dist/server/routes/health.js +19 -2
  9. package/dist/server/routes/settings.js +24 -0
  10. package/dist/server/routes/workspaces.js +379 -72
  11. package/dist/server/services/agent/engines/claude-code/engine.js +39 -35
  12. package/dist/server/services/agent/engines/codex/client.js +16 -4
  13. package/dist/server/services/agent/engines/codex/engine.js +23 -2
  14. package/dist/server/services/agent/engines/codex/jsonrpc/peer.js +44 -5
  15. package/dist/server/services/agent/engines/types.js +13 -0
  16. package/dist/server/services/agent/orchestrator.js +354 -133
  17. package/dist/server/services/agent/session-controller.js +27 -0
  18. package/dist/server/services/agent/{engines/codex/turn-liveness.js → turn-liveness.js} +6 -2
  19. package/dist/server/services/change-source-branch-service.js +115 -11
  20. package/dist/server/services/db-backup-service.js +27 -16
  21. package/dist/server/services/pr-watcher-service.js +17 -2
  22. package/dist/server/services/settings-service.js +39 -0
  23. package/dist/server/services/websocket-service.js +114 -48
  24. package/dist/server/services/workspace-service.js +54 -2
  25. package/dist/server/services/worktree-purge-service.js +19 -5
  26. package/dist/server/services/worktree-service.js +38 -1
  27. package/dist/server/services/ws-events-retention-service.js +133 -0
  28. package/dist/server/utils/git-ops.js +209 -14
  29. package/dist/server/utils/git-repo-lock.js +45 -0
  30. package/dist/server/utils/logger.js +108 -0
  31. package/dist/server/utils/script-runner.js +11 -0
  32. package/package.json +1 -1
  33. package/src/client/dist/pwa/assets/ActivityFeed-BlDBh5jB.js +8 -0
  34. package/src/client/dist/pwa/assets/ActivityFeed-tC8sR0GV.css +1 -0
  35. package/src/client/dist/pwa/assets/ChangelogPage-DACtknly.js +1 -0
  36. package/src/client/dist/pwa/assets/ChangelogPage-D_WOVByV.css +1 -0
  37. package/src/client/dist/pwa/assets/ClosePopup-DEy0yFXE.js +1 -0
  38. package/src/client/dist/pwa/assets/CreatePage-Bb-Qn-Rd.js +2 -0
  39. package/src/client/dist/pwa/assets/CreatePage-RMBX-Zjn.css +1 -0
  40. package/src/client/dist/pwa/assets/DiffViewer-DKt6gFi4.js +8 -0
  41. package/src/client/dist/pwa/assets/DiffViewer-ahWtFrRy.css +1 -0
  42. package/src/client/dist/pwa/assets/DocumentsPanel-B8QY7FTj.css +1 -0
  43. package/src/client/dist/pwa/assets/DocumentsPanel-DhP1jk11.js +1 -0
  44. package/src/client/dist/pwa/assets/DrawerToggleButton-CBdOJzVZ.js +1 -0
  45. package/src/client/dist/pwa/assets/GitPanel-BKrtkPrV.css +1 -0
  46. package/src/client/dist/pwa/assets/GitPanel-DNCCB0G_.js +3 -0
  47. package/src/client/dist/pwa/assets/HealthPage-B1J4-dOH.js +1 -0
  48. package/src/client/dist/pwa/assets/HealthPage-BbqLoFQj.css +1 -0
  49. package/src/client/dist/pwa/assets/MainLayout-F8ShDnAy.css +1 -0
  50. package/src/client/dist/pwa/assets/MainLayout-qWwDy1B5.js +2 -0
  51. package/src/client/dist/pwa/assets/QBadge-BgS0-MXB.js +1 -0
  52. package/src/client/dist/pwa/assets/QBanner-BoAbhM1O.js +1 -0
  53. package/src/client/dist/pwa/assets/QBtn-B7WFdfmy.js +1 -0
  54. package/src/client/dist/pwa/assets/QBtnGroup-BMQlX2YS.js +1 -0
  55. package/src/client/dist/pwa/assets/QBtnToggle-eMu_yinY.js +1 -0
  56. package/src/client/dist/pwa/assets/QChip-BzB_GF4Z.js +1 -0
  57. package/src/client/dist/pwa/assets/QExpansionItem-C8CWo-aP.js +1 -0
  58. package/src/client/dist/pwa/assets/QItemSection-CDBweQnA.js +1 -0
  59. package/src/client/dist/pwa/assets/QList-Ccqhimcb.js +1 -0
  60. package/src/client/dist/pwa/assets/QPage-RpDxx5ah.js +1 -0
  61. package/src/client/dist/pwa/assets/QSelect-D9bHHxAA.js +1 -0
  62. package/src/client/dist/pwa/assets/QSlideTransition-6lWJvjLP.js +1 -0
  63. package/src/client/dist/pwa/assets/QSpace-D3PNLwa4.js +1 -0
  64. package/src/client/dist/pwa/assets/QSpinnerDots-DWOFEezR.js +1 -0
  65. package/src/client/dist/pwa/assets/SchedulePanel-Brtqtz1H.js +1 -0
  66. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +1 -0
  67. package/src/client/dist/pwa/assets/SearchPage-CvZxJiJs.js +1 -0
  68. package/src/client/dist/pwa/assets/SearchPage-DdWM01rq.css +1 -0
  69. package/src/client/dist/pwa/assets/SettingsPage-JVje8OPs.js +16 -0
  70. package/src/client/dist/pwa/assets/SettingsPage-dMAg4JP8.css +1 -0
  71. package/src/client/dist/pwa/assets/TerminalPanel-B6xJJs8p.css +1 -0
  72. package/src/client/dist/pwa/assets/TerminalPanel-Dtc6B3US.js +18 -0
  73. package/src/client/dist/pwa/assets/TouchPan-DnEDaTFL.js +1 -0
  74. package/src/client/dist/pwa/assets/WorkspacePage-BVxtefbj.js +4 -0
  75. package/src/client/dist/pwa/assets/WorkspacePage-DN-A6XpI.css +1 -0
  76. package/src/client/dist/pwa/assets/build-path-tree-BU8Y8vRw.js +1 -0
  77. package/src/client/dist/pwa/assets/clipboard-t9qe6lK7.js +1 -0
  78. package/src/client/dist/pwa/assets/{cssMode-DNEi2cLH.js → cssMode-D_JDSbNP.js} +1 -1
  79. package/src/client/dist/pwa/assets/debounce-CDIE7CV6.js +1 -0
  80. package/src/client/dist/pwa/assets/documents-B6eOZeFK.js +1 -0
  81. package/src/client/dist/pwa/assets/{editor.api-_tFrtCR7.js → editor.api-Ccbr7NXr.js} +1 -1
  82. package/src/client/dist/pwa/assets/engineFeatures-DW9PydRL.js +1 -0
  83. package/src/client/dist/pwa/assets/expand-template-B4LAsXAF.css +1 -0
  84. package/src/client/dist/pwa/assets/expand-template-Xq6JwXsl.js +1 -0
  85. package/src/client/dist/pwa/assets/{freemarker2-kfVsFQjE.js → freemarker2-CUvQzJdx.js} +1 -1
  86. package/src/client/dist/pwa/assets/{handlebars-EwBi9SHt.js → handlebars-BXiAPlES.js} +1 -1
  87. package/src/client/dist/pwa/assets/{html-BOOCP_vn.js → html-CLmfyTMC.js} +1 -1
  88. package/src/client/dist/pwa/assets/{htmlMode-IN9oEIXW.js → htmlMode-DWYo6_e_.js} +1 -1
  89. package/src/client/dist/pwa/assets/i18n-CJclRByY.js +1 -0
  90. package/src/client/dist/pwa/assets/index-Bri8-39E.css +1 -0
  91. package/src/client/dist/pwa/assets/index-Dv1p-1sA.js +82 -0
  92. package/src/client/dist/pwa/assets/{javascript-CfV8qmeH.js → javascript-C9gWFLC_.js} +1 -1
  93. package/src/client/dist/pwa/assets/{jsonMode-C0b3m4VO.js → jsonMode-CbsjeFb8.js} +1 -1
  94. package/src/client/dist/pwa/assets/kobo-commands-DGygmOvc.js +9 -0
  95. package/src/client/dist/pwa/assets/layout-nWIyHeJf.js +1 -0
  96. package/src/client/dist/pwa/assets/{liquid-D3YK93J-.js → liquid-BqJ3SIha.js} +1 -1
  97. package/src/client/dist/pwa/assets/{lspLanguageFeatures-PklP2gEH.js → lspLanguageFeatures-BB0y1B51.js} +1 -1
  98. package/src/client/dist/pwa/assets/{mdx-DjZcevPS.js → mdx-rIxQLZDB.js} +1 -1
  99. package/src/client/dist/pwa/assets/network-auth-pzKTXRYP.js +1 -0
  100. package/src/client/dist/pwa/assets/notify-theme-D7RrwvJa.js +1 -0
  101. package/src/client/dist/pwa/assets/pending-diff-open-Cia8sndE.js +1 -0
  102. package/src/client/dist/pwa/assets/private.use-form-D8Yuc3aU.js +1 -0
  103. package/src/client/dist/pwa/assets/{project-color-Cc9yKGk3.js → project-color-Dur1z6Gn.js} +1 -1
  104. package/src/client/dist/pwa/assets/{python-BrgzhqrC.js → python-C6d5du9h.js} +1 -1
  105. package/src/client/dist/pwa/assets/{razor-g8NeeeQc.js → razor-Aek1yDc0.js} +1 -1
  106. package/src/client/dist/pwa/assets/{render-chat-markdown-CZyJL1-w.js → render-chat-markdown-B6sa44Xr.js} +2 -2
  107. package/src/client/dist/pwa/assets/skill-suite-prompts-DdoGRwhz.js +36 -0
  108. package/src/client/dist/pwa/assets/{tsMode-DAopv4hH.js → tsMode-rxNEyqyY.js} +1 -1
  109. package/src/client/dist/pwa/assets/{typescript-C2jZCVPF.js → typescript-DndxWYhC.js} +1 -1
  110. package/src/client/dist/pwa/assets/use-checkbox-z-Qs4ZHY.js +1 -0
  111. package/src/client/dist/pwa/assets/use-id-DlouqdB8.js +1 -0
  112. package/src/client/dist/pwa/assets/use-onboarding-BzSwN4xT.js +2 -0
  113. package/src/client/dist/pwa/assets/{vs-mk-rn9sP.js → vs-De8HKJ9r.js} +3 -3
  114. package/src/client/dist/pwa/assets/{workers-CP5iY9Kz.js → workers-DdNkyVm9.js} +1 -1
  115. package/src/client/dist/pwa/assets/{xml-D3pYNERH.js → xml-Cl1k1mQr.js} +1 -1
  116. package/src/client/dist/pwa/assets/{yaml-BTRPY5XP.js → yaml-C8G_k5TR.js} +1 -1
  117. package/src/client/dist/pwa/fonts/GEIST-LICENSE.txt +92 -0
  118. package/src/client/dist/pwa/fonts/Geist-Variable.woff2 +0 -0
  119. package/src/client/dist/pwa/fonts/GeistMono-Variable.woff2 +0 -0
  120. package/src/client/dist/pwa/index.html +7 -5
  121. package/src/client/dist/pwa/sw.js +1 -1
  122. package/src/mcp-server/kobo-tasks-server.ts +8 -7
  123. package/dist/server/utils/process-tracker.js +0 -49
  124. package/src/client/dist/pwa/assets/ActivityFeed-BrjeLlhi.css +0 -1
  125. package/src/client/dist/pwa/assets/ActivityFeed-WBBk7DgE.js +0 -8
  126. package/src/client/dist/pwa/assets/ChangelogPage-Cr5cW9Wx.js +0 -1
  127. package/src/client/dist/pwa/assets/ChangelogPage-DGhfnkax.css +0 -1
  128. package/src/client/dist/pwa/assets/ClosePopup-2KSSXX6P.js +0 -1
  129. package/src/client/dist/pwa/assets/CreatePage-Bl1Vjr27.css +0 -1
  130. package/src/client/dist/pwa/assets/CreatePage-DQk8TrUP.js +0 -2
  131. package/src/client/dist/pwa/assets/DiffViewer-C9xV8dh-.js +0 -8
  132. package/src/client/dist/pwa/assets/DiffViewer-CS3P9Ngi.css +0 -1
  133. package/src/client/dist/pwa/assets/DrawerToggleButton-CjM28eaw.js +0 -1
  134. package/src/client/dist/pwa/assets/HealthPage-BbLpLRjV.js +0 -1
  135. package/src/client/dist/pwa/assets/HealthPage-BqirO5le.css +0 -1
  136. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuYjalmUiAw-BCOjigVR.woff +0 -0
  137. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuZtalmUiAw-Ocp2tNig.woff +0 -0
  138. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWuaabVmUiAw-6jEn9xEl.woff +0 -0
  139. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWub2bVmUiAw-CCs8I9zJ.woff +0 -0
  140. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbFmUiAw-BLaboPi0.woff +0 -0
  141. package/src/client/dist/pwa/assets/KFOMCnqEu92Fr1ME7kSn66aGLdTylUAMQXC89YmC2DPNWubEbVmUiAw-DHuK6FBl.woff +0 -0
  142. package/src/client/dist/pwa/assets/MainLayout-BUril7SK.js +0 -19
  143. package/src/client/dist/pwa/assets/MainLayout-u-AM87m3.css +0 -1
  144. package/src/client/dist/pwa/assets/QBadge-DS9cpnyx.js +0 -1
  145. package/src/client/dist/pwa/assets/QBanner-tANb95XA.js +0 -1
  146. package/src/client/dist/pwa/assets/QBtn-DJGkBel8.js +0 -1
  147. package/src/client/dist/pwa/assets/QBtnToggle-D-Hsqmcb.js +0 -1
  148. package/src/client/dist/pwa/assets/QChip-iLXQ5Khf.js +0 -1
  149. package/src/client/dist/pwa/assets/QItemSection-B-5iFRBZ.js +0 -1
  150. package/src/client/dist/pwa/assets/QList-D-yd1vEv.js +0 -1
  151. package/src/client/dist/pwa/assets/QSelect-TFRaHbzG.js +0 -36
  152. package/src/client/dist/pwa/assets/QSeparator-C_2BuEw9.js +0 -1
  153. package/src/client/dist/pwa/assets/QSpace-BuMMeXnK.js +0 -1
  154. package/src/client/dist/pwa/assets/QSpinnerDots-C-tv95pD.js +0 -1
  155. package/src/client/dist/pwa/assets/SearchPage-CPM7L9_M.js +0 -1
  156. package/src/client/dist/pwa/assets/SearchPage-CngtAqls.css +0 -1
  157. package/src/client/dist/pwa/assets/SettingsPage-BRYycuYY.js +0 -16
  158. package/src/client/dist/pwa/assets/SettingsPage-D5BacmAr.css +0 -1
  159. package/src/client/dist/pwa/assets/TouchPan-79_u9-Fd.js +0 -1
  160. package/src/client/dist/pwa/assets/WorkspacePage-DB1n1bmg.js +0 -4
  161. package/src/client/dist/pwa/assets/WorkspacePage-hxEpXqBA.css +0 -1
  162. package/src/client/dist/pwa/assets/documents-BMvE9Q_U.js +0 -1
  163. package/src/client/dist/pwa/assets/engineFeatures-JkVb6hry.js +0 -1
  164. package/src/client/dist/pwa/assets/expand-template-D2yUa54D.css +0 -1
  165. package/src/client/dist/pwa/assets/expand-template-Dl4wUjF8.js +0 -1
  166. package/src/client/dist/pwa/assets/i18n-Dezgz2hU.js +0 -1
  167. package/src/client/dist/pwa/assets/index-B12jWMl8.js +0 -82
  168. package/src/client/dist/pwa/assets/index-CK1nk7qr.css +0 -1
  169. package/src/client/dist/pwa/assets/kobo-commands-DznaKlq6.js +0 -9
  170. package/src/client/dist/pwa/assets/layout-me0gwmBV.js +0 -1
  171. package/src/client/dist/pwa/assets/network-auth-BMTuTKHx.js +0 -1
  172. package/src/client/dist/pwa/assets/pending-diff-open-BG3IOnWw.js +0 -1
  173. package/src/client/dist/pwa/assets/use-checkbox-D-9LXk-S.js +0 -1
  174. package/src/client/dist/pwa/assets/use-is-mobile-DX2Fiee-.js +0 -1
  175. package/src/client/dist/pwa/assets/use-onboarding-BNo8mb65.js +0 -2
@@ -41,6 +41,7 @@ import * as purgeWorktreeService from '../services/worktree-purge-service.js';
41
41
  import * as worktreeService from '../services/worktree-service.js';
42
42
  import { resolveUniqueBranchAndPath } from '../utils/branch-resolver.js';
43
43
  import * as gitOps from '../utils/git-ops.js';
44
+ import { logError } from '../utils/logger.js';
44
45
  import { slugifyProjectName } from '../utils/project-slug.js';
45
46
  import * as safePath from '../utils/safe-path.js';
46
47
  import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js';
@@ -48,6 +49,124 @@ import { resolveSiblingWorkspaceWorktreePath } from '../utils/worktree-paths.js'
48
49
  const app = new Hono();
49
50
  /** Tracks workspaces currently running a setup script to prevent concurrent executions. */
50
51
  const setupScriptRunning = new Set();
52
+ /**
53
+ * Ordered, named steps of `POST /api/workspaces`. The handler already runs
54
+ * these sequentially; naming them is what lets the create page say WHAT it is
55
+ * waiting on instead of showing an undifferentiated spinner for what can be
56
+ * several minutes (a user-supplied setup script runs inside this request).
57
+ */
58
+ export const CREATE_WORKSPACE_STEPS = [
59
+ 'validate',
60
+ 'fetch-source-branch',
61
+ 'inspect-worktree',
62
+ 'extract-notion',
63
+ 'extract-sentry',
64
+ 'create-record',
65
+ 'create-tasks',
66
+ 'create-worktree',
67
+ 'write-conventions',
68
+ 'write-context-files',
69
+ 'build-prompt',
70
+ 'setup-script',
71
+ 'start-agent',
72
+ // Never reached on a happy path. Emitted while the handler UNDOES a failed
73
+ // creation, so the page stops showing the last step it managed to reach.
74
+ 'rollback',
75
+ 'done',
76
+ ];
77
+ /**
78
+ * Emit one creation-progress beat.
79
+ *
80
+ * The channel is the client-supplied `creationId`, NOT a workspace id: while
81
+ * this handler runs, the workspace row may not exist yet and the client has
82
+ * no id to subscribe to (the POST response is still in flight). A WebSocket
83
+ * subscription is just a string in a Set, and `emitEphemeral` never persists,
84
+ * so there is no foreign key to satisfy — the existing machinery works as-is.
85
+ *
86
+ * No `creationId` (API consumers, tests) → no-op.
87
+ */
88
+ function emitCreateProgress(creationId, step) {
89
+ if (!creationId)
90
+ return;
91
+ try {
92
+ wsService.emitEphemeral(creationId, 'workspace:create-progress', {
93
+ creationId,
94
+ step,
95
+ index: CREATE_WORKSPACE_STEPS.indexOf(step),
96
+ total: CREATE_WORKSPACE_STEPS.length,
97
+ });
98
+ }
99
+ catch (err) {
100
+ // Best-effort: a broken progress beat must never fail workspace creation.
101
+ console.error(`[workspaces] emitCreateProgress failed for step '${step}':`, err);
102
+ }
103
+ }
104
+ /**
105
+ * What a rollback provably cannot reach. The setup script may have installed
106
+ * dependencies in a global cache, started containers or written files outside
107
+ * the worktree — say so rather than implying a total undo.
108
+ */
109
+ const SETUP_SCRIPT_ROLLBACK_CAVEAT = ' The setup script had already run: anything it created outside the worktree (containers, global caches, files elsewhere) was NOT undone.';
110
+ /** Terminal failure beat — names the step that broke, so the user never has to guess. */
111
+ function emitCreateFailed(creationId, step, message) {
112
+ if (!creationId)
113
+ return;
114
+ try {
115
+ wsService.emitEphemeral(creationId, 'workspace:create-failed', { creationId, step, message });
116
+ }
117
+ catch (err) {
118
+ // Best-effort: a broken failure beat must never mask or replace the real error.
119
+ console.error(`[workspaces] emitCreateFailed failed for step '${step}':`, err);
120
+ }
121
+ }
122
+ /**
123
+ * Undo a creation that failed after the workspace row existed.
124
+ *
125
+ * Delegates to `deleteWorkspaceWithSideEffects` — the module's ONE demolition
126
+ * path — rather than growing a second cleanup that would drift from it. That
127
+ * function already stops the agent, stops the dev server, destroys the
128
+ * terminal, skips a worktree the user brought (`worktreeOwned === false`), and
129
+ * collects side-effect failures as warnings without ever throwing.
130
+ *
131
+ * It is declared with `async function` further down the file, so it is hoisted
132
+ * and callable from here.
133
+ *
134
+ * IMPORTANT: this is best-effort cleanup. It must NEVER throw, and it must
135
+ * NEVER become the error the caller reports — the original cause always wins.
136
+ */
137
+ async function rollbackFailedCreation(workspace, opts) {
138
+ try {
139
+ return await deleteWorkspaceWithSideEffects(workspace, {
140
+ deleteLocalBranch: opts.deleteLocalBranch,
141
+ // Nothing was ever pushed at this point in the creation flow.
142
+ deleteRemoteBranch: false,
143
+ });
144
+ }
145
+ catch (err) {
146
+ const message = err instanceof Error ? err.message : String(err);
147
+ console.error(`[workspaces] rollback of '${workspace.id}' failed:`, message);
148
+ return [`Rollback of workspace '${workspace.name}' failed: ${message}`];
149
+ }
150
+ }
151
+ /**
152
+ * Move a freshly created workspace to `brainstorming`, never throwing.
153
+ *
154
+ * This transition is cosmetic — the agent starts either way — but it sits
155
+ * inside the creation try block, so letting it throw would hand a live
156
+ * workspace to the rollback path and demolish it. The realistic trigger is a
157
+ * race: a long setup script leaves the row visible in the sidebar, the user
158
+ * clicks Start, the agent moves the workspace to `executing`, and
159
+ * `executing -> brainstorming` is then a rejected transition. Losing the
160
+ * status beat is a cosmetic bug; losing the worktree is data loss.
161
+ */
162
+ function markBrainstormingBestEffort(workspaceId) {
163
+ try {
164
+ workspaceService.updateWorkspaceStatus(workspaceId, 'brainstorming');
165
+ }
166
+ catch (err) {
167
+ console.error(`[workspaces] could not mark '${workspaceId}' as brainstorming:`, err);
168
+ }
169
+ }
51
170
  const VALID_AGENT_PERMISSION_MODES = ['plan', 'bypass', 'strict', 'interactive'];
52
171
  const VALID_WORKSPACE_STATUSES = [
53
172
  'created',
@@ -144,8 +263,10 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
144
263
  const handoff = typeof body.handoff === 'string' && body.handoff.trim()
145
264
  ? body.handoff.trim().slice(0, 30_000)
146
265
  : buildEngineHandoff(workspace, workspace.engine, body.engine);
266
+ // A new engine is started three lines below: the previous one must be dead
267
+ // first, or two agents share the worktree.
147
268
  if (agentManager.hasController(id))
148
- agentManager.stopAgent(id);
269
+ await agentManager.stopAgentAndWait(id);
149
270
  const updated = workspaceService.updateWorkspaceEngineConfiguration(id, body.engine, model, effort, body.agentPermissionMode);
150
271
  const session = agentManager.startAgent(id, updated.worktreePath, handoff, updated.model, false, updated.agentPermissionMode, undefined, updated.reasoningEffort);
151
272
  workspaceService.updateWorkspaceStatus(id, 'executing');
@@ -158,6 +279,25 @@ app.post('/:id/switch-engine', migrationGuard, async (c) => {
158
279
  });
159
280
  // POST /api/workspaces — create workspace
160
281
  app.post('/', migrationGuard, async (c) => {
282
+ // Declared outside the try so the outermost catch (any unforeseen throw,
283
+ // including a malformed request body) can still name a step instead of
284
+ // leaving the create-progress channel silent. Updated in lockstep with
285
+ // every `emitCreateProgress` call below via the assignment-expression
286
+ // argument, so it always reflects the last beat actually emitted.
287
+ let creationId;
288
+ let currentStep = 'validate';
289
+ // The workspace row, as soon as it exists, so the outermost catch can undo a
290
+ // creation that broke on a step with no handler of its own. Only `name` ever
291
+ // changes afterwards (Notion / Sentry title), and the rollback uses it for
292
+ // log text alone — every field it acts on is fixed at creation time.
293
+ let createdWorkspace = null;
294
+ // Set only once WE created the worktree, which is also what created the
295
+ // branch. A failure before that point must not delete a branch we never made.
296
+ let createdBranch = false;
297
+ // Hoisted out of the `build-prompt` block (declared with `const` there) so
298
+ // both rollback paths — the `start-agent` branch and the outermost catch —
299
+ // can read it to decide whether the setup-script caveat applies.
300
+ let setupScriptConfigured = false;
161
301
  try {
162
302
  const body = await c.req.json();
163
303
  // workingBranch is derived from git when worktreePath is provided, so
@@ -168,6 +308,17 @@ app.post('/', migrationGuard, async (c) => {
168
308
  if (!body.worktreePath && !body.workingBranch) {
169
309
  return c.json({ error: 'Missing required field: workingBranch' }, 400);
170
310
  }
311
+ // Only when the caller actually supplies it: when worktreePath is given
312
+ // instead, workingBranch is derived from git itself further down, not
313
+ // user input. A name starting with `-` would otherwise read as a git
314
+ // option (`--upload-pack=…`) rather than a branch name — see
315
+ // isValidBranchName's own doc comment for why this specific shape matters.
316
+ if (body.workingBranch && !gitOps.isValidBranchName(body.workingBranch)) {
317
+ return c.json({ error: `Invalid working branch name: ${body.workingBranch}` }, 400);
318
+ }
319
+ creationId = typeof body.creationId === 'string' && body.creationId.length > 0 ? body.creationId : undefined;
320
+ currentStep = 'validate';
321
+ emitCreateProgress(creationId, currentStep);
171
322
  // Validate the engine id (if provided) against the registry. An unknown
172
323
  // engine is rejected up-front so we don't create orphan workspaces that
173
324
  // can't spawn an agent.
@@ -175,7 +326,9 @@ app.post('/', migrationGuard, async (c) => {
175
326
  const engines = listEngines();
176
327
  const validEngineIds = engines.map((e) => e.id);
177
328
  if (!validEngineIds.includes(body.engine)) {
178
- return c.json({ error: `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}` }, 400);
329
+ const message = `Unknown engine '${body.engine}'. Valid engines: ${validEngineIds.join(', ')}`;
330
+ emitCreateFailed(creationId, 'validate', message);
331
+ return c.json({ error: message, step: 'validate' }, 400);
179
332
  }
180
333
  // Cross-validate engine × permission mode: each engine declares which
181
334
  // modes it supports via `capabilities.permissionModes`. The UI already
@@ -186,9 +339,9 @@ app.post('/', migrationGuard, async (c) => {
186
339
  const engine = engines.find((e) => e.id === body.engine);
187
340
  const supported = engine?.capabilities.permissionModes ?? [];
188
341
  if (!supported.includes(body.agentPermissionMode)) {
189
- return c.json({
190
- error: `Engine '${body.engine}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`,
191
- }, 400);
342
+ const message = `Engine '${body.engine}' does not support agentPermissionMode '${body.agentPermissionMode}'. Supported: ${supported.join(', ')}`;
343
+ emitCreateFailed(creationId, 'validate', message);
344
+ return c.json({ error: message, step: 'validate' }, 400);
192
345
  }
193
346
  }
194
347
  }
@@ -197,6 +350,8 @@ app.post('/', migrationGuard, async (c) => {
197
350
  // and only hard-block (422) when neither origin nor a local branch is usable.
198
351
  // The reuse path (body.worktreePath) needs no base ref, so a failed fetch is
199
352
  // simply logged there.
353
+ currentStep = 'fetch-source-branch';
354
+ emitCreateProgress(creationId, currentStep);
200
355
  const isReuseRequest = !!body.worktreePath;
201
356
  let baseRef = `origin/${body.sourceBranch}`;
202
357
  let usedLocalFallback = false;
@@ -214,7 +369,9 @@ app.post('/', migrationGuard, async (c) => {
214
369
  console.warn(`[workspaces] fetch of '${body.sourceBranch}' from origin failed; falling back to local branch: ${message}`);
215
370
  }
216
371
  else {
217
- return c.json({ error: `${message} — and no local branch '${body.sourceBranch}' exists to fall back on` }, 422);
372
+ const failure = `${message} — and no local branch '${body.sourceBranch}' exists to fall back on`;
373
+ emitCreateFailed(creationId, 'fetch-source-branch', failure);
374
+ return c.json({ error: failure, step: 'fetch-source-branch' }, 422);
218
375
  }
219
376
  }
220
377
  // Reuse-existing-worktree path. When the caller passes `worktreePath`,
@@ -224,9 +381,15 @@ app.post('/', migrationGuard, async (c) => {
224
381
  // the working branch from git itself — the body.workingBranch is ignored.
225
382
  let useReusedWorktree = false;
226
383
  let reusedDerivedBranch = null;
384
+ if (body.worktreePath) {
385
+ currentStep = 'inspect-worktree';
386
+ emitCreateProgress(creationId, currentStep);
387
+ }
227
388
  if (body.worktreePath) {
228
389
  if (!fs.existsSync(body.worktreePath)) {
229
- return c.json({ error: `Worktree path does not exist: ${body.worktreePath}` }, 422);
390
+ const failure = `Worktree path does not exist: ${body.worktreePath}`;
391
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
392
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
230
393
  }
231
394
  try {
232
395
  const commonDir = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--git-common-dir'], {
@@ -234,25 +397,33 @@ app.post('/', migrationGuard, async (c) => {
234
397
  }).trim();
235
398
  const expectedCommonDir = path.join(body.projectPath, '.git');
236
399
  if (path.resolve(commonDir) !== path.resolve(expectedCommonDir)) {
237
- return c.json({ error: `Worktree '${body.worktreePath}' belongs to a different repository` }, 422);
400
+ const failure = `Worktree '${body.worktreePath}' belongs to a different repository`;
401
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
402
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
238
403
  }
239
404
  const branch = execFileSync('git', ['-C', body.worktreePath, 'rev-parse', '--abbrev-ref', 'HEAD'], {
240
405
  encoding: 'utf-8',
241
406
  }).trim();
242
407
  if (!branch || branch === 'HEAD') {
243
- return c.json({ error: 'Worktree is in detached HEAD state and cannot be attached' }, 422);
408
+ const failure = 'Worktree is in detached HEAD state and cannot be attached';
409
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
410
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
244
411
  }
245
412
  reusedDerivedBranch = branch;
246
413
  }
247
414
  catch (err) {
248
415
  const message = err instanceof Error ? err.message : String(err);
249
- return c.json({ error: `Failed to inspect worktree: ${message}` }, 422);
416
+ const failure = `Failed to inspect worktree: ${message}`;
417
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
418
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
250
419
  }
251
420
  // Validate the worktree isn't already attached to another workspace.
252
421
  const dbForCheck = getDb();
253
422
  const existing = dbForCheck.prepare('SELECT id FROM workspaces WHERE worktree_path = ?').get(body.worktreePath);
254
423
  if (existing) {
255
- return c.json({ error: 'This worktree is already attached to another Kōbō workspace' }, 422);
424
+ const failure = 'This worktree is already attached to another Kōbō workspace';
425
+ emitCreateFailed(creationId, 'inspect-worktree', failure);
426
+ return c.json({ error: failure, step: 'inspect-worktree' }, 422);
256
427
  }
257
428
  useReusedWorktree = true;
258
429
  }
@@ -261,15 +432,21 @@ app.post('/', migrationGuard, async (c) => {
261
432
  // before createWorkspace and surface failures as 422.
262
433
  let notionContent = null;
263
434
  if (body.notionUrl) {
435
+ currentStep = 'extract-notion';
436
+ emitCreateProgress(creationId, currentStep);
264
437
  if (!settingsService.getGlobalSettings().notionEnabled) {
265
- return c.json({ error: 'Notion integration is disabled in Settings' }, 403);
438
+ const failure = 'Notion integration is disabled in Settings';
439
+ emitCreateFailed(creationId, 'extract-notion', failure);
440
+ return c.json({ error: failure, step: 'extract-notion' }, 403);
266
441
  }
267
442
  try {
268
443
  notionContent = await notionService.extractNotionPage(body.notionUrl);
269
444
  }
270
445
  catch (err) {
271
446
  const message = err instanceof Error ? err.message : String(err);
272
- return c.json({ error: `Failed to extract Notion page: ${message}` }, 422);
447
+ const failure = `Failed to extract Notion page: ${message}`;
448
+ emitCreateFailed(creationId, 'extract-notion', failure);
449
+ return c.json({ error: failure, step: 'extract-notion' }, 422);
273
450
  }
274
451
  const assigneeProperty = settingsService.getGlobalSettings().notionAssigneeProperty;
275
452
  if (assigneeProperty && body.notionUrl) {
@@ -286,15 +463,21 @@ app.post('/', migrationGuard, async (c) => {
286
463
  }
287
464
  let sentryContent = null;
288
465
  if (body.sentryUrl) {
466
+ currentStep = 'extract-sentry';
467
+ emitCreateProgress(creationId, currentStep);
289
468
  if (!settingsService.getGlobalSettings().sentryEnabled) {
290
- return c.json({ error: 'Sentry integration is disabled in Settings' }, 403);
469
+ const failure = 'Sentry integration is disabled in Settings';
470
+ emitCreateFailed(creationId, 'extract-sentry', failure);
471
+ return c.json({ error: failure, step: 'extract-sentry' }, 403);
291
472
  }
292
473
  try {
293
474
  sentryContent = await sentryService.extractSentryIssue(body.sentryUrl);
294
475
  }
295
476
  catch (err) {
296
477
  const message = err instanceof Error ? err.message : String(err);
297
- return c.json({ error: `Failed to extract Sentry issue: ${message}` }, 422);
478
+ const failure = `Failed to extract Sentry issue: ${message}`;
479
+ emitCreateFailed(creationId, 'extract-sentry', failure);
480
+ return c.json({ error: failure, step: 'extract-sentry' }, 422);
298
481
  }
299
482
  if (sentryContent && sentryContent.assignee.length === 0) {
300
483
  const sentryUrl = body.sentryUrl;
@@ -358,6 +541,8 @@ app.post('/', migrationGuard, async (c) => {
358
541
  // request — keeps the user's flow smooth at the cost of a longer
359
542
  // branch name. The same hash is applied to BOTH the branch and the
360
543
  // worktree path so they stay aligned.
544
+ currentStep = 'create-record';
545
+ emitCreateProgress(creationId, currentStep);
361
546
  let prospectiveWorktreePath;
362
547
  let workingBranchAdjusted = false;
363
548
  if (useReusedWorktree) {
@@ -377,7 +562,8 @@ app.post('/', migrationGuard, async (c) => {
377
562
  }
378
563
  catch (err) {
379
564
  const message = err instanceof Error ? err.message : String(err);
380
- return c.json({ error: message }, 409);
565
+ emitCreateFailed(creationId, 'create-record', message);
566
+ return c.json({ error: message, step: 'create-record' }, 409);
381
567
  }
382
568
  }
383
569
  let workspace = workspaceService.createWorkspace({
@@ -397,6 +583,7 @@ app.post('/', migrationGuard, async (c) => {
397
583
  engine: body.engine,
398
584
  ...(useReusedWorktree ? {} : { worktreesPath: globalSettings.worktreesPath }),
399
585
  });
586
+ createdWorkspace = workspace;
400
587
  // Enable auto-loop before starting the initial brainstorming session so
401
588
  // its model override is available when selecting that session's model.
402
589
  if (body.autoLoop === true) {
@@ -443,6 +630,8 @@ app.post('/', migrationGuard, async (c) => {
443
630
  const prefix = sentryContent.issueId ? `${sentryContent.issueId} | ` : '';
444
631
  workspace = workspaceService.updateWorkspaceName(workspace.id, `${prefix}${sentryContent.title}`);
445
632
  }
633
+ currentStep = 'create-tasks';
634
+ emitCreateProgress(creationId, currentStep);
446
635
  // Create tasks from extracted Notion data
447
636
  if (notionContent) {
448
637
  let sortOrder = 0;
@@ -493,6 +682,8 @@ app.post('/', migrationGuard, async (c) => {
493
682
  }
494
683
  }
495
684
  }
685
+ currentStep = 'create-worktree';
686
+ emitCreateProgress(creationId, currentStep);
496
687
  // Create git worktree for the working branch — unless we're reusing an
497
688
  // existing one, in which case the path is taken straight from the body.
498
689
  let worktreePath;
@@ -503,27 +694,24 @@ app.post('/', migrationGuard, async (c) => {
503
694
  try {
504
695
  const created = worktreeService.createWorktree(body.projectPath, workingBranch, baseRef, globalSettings.worktreesPath, projectSlug);
505
696
  worktreePath = created.worktreePath;
697
+ // Not an assumption: `createWorktree` reports which git command it ran.
698
+ // It falls back to attaching an existing branch, and deleting that one
699
+ // on rollback would destroy commits the user made before Kobo existed.
700
+ createdBranch = created.branchCreated;
506
701
  }
507
702
  catch (err) {
508
703
  const message = err instanceof Error ? err.message : String(err);
509
- // Roll back the half-created workspace: a failed worktree creation must NOT
510
- // leave an orphan record behind. Best-effort cleanup of any partial worktree
511
- // dir git may have produced, then delete the just-inserted workspace row.
512
- try {
513
- worktreeService.removeWorktree(body.projectPath, prospectiveWorktreePath);
514
- }
515
- catch {
516
- // worktree may never have been registered — ignore
517
- }
518
- try {
519
- workspaceService.deleteWorkspace(workspace.id);
520
- }
521
- catch (delErr) {
522
- console.error('[workspaces] rollback deleteWorkspace failed:', delErr);
523
- }
524
- return c.json({ error: `Failed to create worktree: ${message}` }, 500);
704
+ // Roll back the half-created workspace through the SHARED demolition
705
+ // path — a second inline cleanup here would drift from it. The branch
706
+ // is not deleted: `createWorktree` failed, so we never created one.
707
+ const rollbackWarnings = await rollbackFailedCreation(workspace, { deleteLocalBranch: false });
708
+ const failure = `Failed to create worktree: ${message}`;
709
+ emitCreateFailed(creationId, 'create-worktree', failure);
710
+ return c.json({ error: failure, step: 'create-worktree', rollback: { done: true, warnings: rollbackWarnings } }, 500);
525
711
  }
526
712
  }
713
+ currentStep = 'write-conventions';
714
+ emitCreateProgress(creationId, currentStep);
527
715
  // Ensure Kobo-generated files are gitignored. Check both the root
528
716
  // .gitignore and .ai/.gitignore to avoid duplicate entries.
529
717
  try {
@@ -554,7 +742,10 @@ app.post('/', migrationGuard, async (c) => {
554
742
  }
555
743
  }
556
744
  catch (err) {
557
- console.error('[workspaces] Failed to update .gitignore:', err);
745
+ logError('workspaces', 'Failed to update .gitignore', {
746
+ workspaceId: workspace.id,
747
+ error: err instanceof Error ? err.message : String(err),
748
+ });
558
749
  }
559
750
  // Write git conventions to the worktree if configured
560
751
  const effectiveSettings = settingsService.getEffectiveSettings(body.projectPath);
@@ -566,13 +757,23 @@ app.post('/', migrationGuard, async (c) => {
566
757
  fs.writeFileSync(conventionsPath, effectiveSettings.gitConventions, 'utf-8');
567
758
  }
568
759
  catch (err) {
569
- console.error('[workspaces] Failed to write .git-conventions.md:', err);
760
+ logError('workspaces', 'Failed to write .git-conventions.md', {
761
+ workspaceId: workspace.id,
762
+ error: err instanceof Error ? err.message : String(err),
763
+ });
570
764
  }
571
765
  }
572
766
  // The setup script runs LATER (after the brainstorm prompt is built and
573
767
  // persisted via setInitialPrompt). This guarantees the prompt survives a
574
768
  // setup-script crash and can be replayed by /:id/start.
575
769
  let setupScriptFailed = false;
770
+ // Captured, not swallowed: a failed engine start used to reach only the
771
+ // server console while the route still answered 201 "created". A
772
+ // half-created workspace lies to the user, so this now drives a full
773
+ // rollback below instead of merely flipping the status to 'error'.
774
+ let agentStartError = null;
775
+ currentStep = 'write-context-files';
776
+ emitCreateProgress(creationId, currentStep);
576
777
  // Save Notion content as markdown in worktree
577
778
  let notionFilePath = null;
578
779
  if (notionContent && body.notionUrl) {
@@ -618,7 +819,10 @@ app.post('/', migrationGuard, async (c) => {
618
819
  fs.writeFileSync(notionFilePath, md, 'utf-8');
619
820
  }
620
821
  catch (err) {
621
- console.error('[workspaces] Failed to save Notion content:', err);
822
+ logError('workspaces', 'Failed to save Notion content', {
823
+ workspaceId: workspace.id,
824
+ error: err instanceof Error ? err.message : String(err),
825
+ });
622
826
  }
623
827
  }
624
828
  // --- Sentry file + task (extraction already done before worktree creation) --
@@ -667,7 +871,10 @@ app.post('/', migrationGuard, async (c) => {
667
871
  });
668
872
  }
669
873
  catch (err) {
670
- console.error('[workspaces] Failed to save Sentry content:', err);
874
+ logError('workspaces', 'Failed to save Sentry content', {
875
+ workspaceId: workspace.id,
876
+ error: err instanceof Error ? err.message : String(err),
877
+ });
671
878
  sentryFilePath = null;
672
879
  }
673
880
  }
@@ -693,6 +900,8 @@ app.post('/', migrationGuard, async (c) => {
693
900
  // VALID_TRANSITIONS contract intact (`created → extracting → brainstorming`
694
901
  // vs `created → brainstorming` when setup is skipped).
695
902
  {
903
+ currentStep = 'build-prompt';
904
+ emitCreateProgress(creationId, currentStep);
696
905
  // Resolve the per-feature initial-prompt templates with single-fallback
697
906
  // semantics: project || global is already handled inside getEffectiveSettings,
698
907
  // and a whitespace-only string acts as a user escape hatch (skip injection).
@@ -842,8 +1051,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
842
1051
  // leaves the workspace in `error` state with `initial_prompt` ready for
843
1052
  // a retry via POST /:id/start. Skipped when reusing an existing worktree
844
1053
  // (rerunning could be destructive — drop a curated node_modules, etc.).
845
- const setupScriptConfigured = effectiveSettings.setupScript && !body.skipSetupScript && !useReusedWorktree;
1054
+ setupScriptConfigured = Boolean(effectiveSettings.setupScript) && !body.skipSetupScript && !useReusedWorktree;
846
1055
  if (setupScriptConfigured) {
1056
+ currentStep = 'setup-script';
1057
+ emitCreateProgress(creationId, currentStep);
847
1058
  workspaceService.updateWorkspaceStatus(workspace.id, 'extracting');
848
1059
  wsService.emit(workspace.id, 'setup:output', { text: '[kobo] Running setup script...' });
849
1060
  try {
@@ -856,9 +1067,10 @@ Once the brainstorming + planning steps above are complete and you have a saved
856
1067
  if (result.exitCode !== 0) {
857
1068
  workspaceService.updateWorkspaceStatus(workspace.id, 'error');
858
1069
  setupScriptFailed = true;
1070
+ emitCreateFailed(creationId, 'setup-script', `Setup script exited with code ${result.exitCode}`);
859
1071
  }
860
1072
  else {
861
- workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
1073
+ markBrainstormingBestEffort(workspace.id);
862
1074
  }
863
1075
  }
864
1076
  catch (err) {
@@ -866,19 +1078,28 @@ Once the brainstorming + planning steps above are complete and you have a saved
866
1078
  console.error(`[workspaces] Setup script error: ${message}`);
867
1079
  workspaceService.updateWorkspaceStatus(workspace.id, 'error');
868
1080
  setupScriptFailed = true;
1081
+ emitCreateFailed(creationId, 'setup-script', message);
869
1082
  }
870
1083
  }
871
1084
  else {
872
1085
  // No setup step → go straight from `created` to `brainstorming`.
873
- workspaceService.updateWorkspaceStatus(workspace.id, 'brainstorming');
1086
+ markBrainstormingBestEffort(workspace.id);
874
1087
  }
875
1088
  if (setupScriptFailed) {
876
1089
  wsService.emit(workspace.id, 'setup:output', {
877
1090
  text: '[kobo] Setup script failed — the agent was NOT started. Your initial prompt has been saved. ' +
878
1091
  'Fix the setup script (Settings → Scripts) and click Start to retry with the original prompt.',
879
1092
  });
1093
+ // This path keeps its deliberate recovery flow (workspace stays in
1094
+ // `error`, prompt persisted, 201 response) — nothing is undone here.
1095
+ // The step is already named on the create-progress channel by the
1096
+ // `emitCreateFailed(creationId, 'setup-script', …)` calls above, at
1097
+ // the point of detection (exit code / thrown error), so the create
1098
+ // page never shows a mute spinner on this path.
880
1099
  }
881
1100
  else {
1101
+ currentStep = 'start-agent';
1102
+ emitCreateProgress(creationId, currentStep);
882
1103
  try {
883
1104
  // The brainstorming session (this very first startAgent call) uses
884
1105
  // brainstormModel when the workspace was created with auto-loop
@@ -906,15 +1127,35 @@ Once the brainstorming + planning steps above are complete and you have a saved
906
1127
  catch (err) {
907
1128
  const message = err instanceof Error ? err.message : String(err);
908
1129
  console.error(`[workspaces] Failed to start agent: ${message}`);
909
- try {
910
- workspaceService.updateWorkspaceStatus(workspace.id, 'error');
911
- }
912
- catch {
913
- /* already logged */
914
- }
1130
+ agentStartError = message;
915
1131
  }
916
1132
  }
917
1133
  }
1134
+ // A workspace whose agent never started is a half-created object, and a
1135
+ // half-created object is exactly what lies to the user. Undo everything
1136
+ // and report the step that broke.
1137
+ if (agentStartError) {
1138
+ emitCreateProgress(creationId, 'rollback');
1139
+ // A worktree the user brought is not ours to remove, and neither is the
1140
+ // branch it was already sitting on. `deleteWorkspaceWithSideEffects`
1141
+ // already guards the worktree via `worktreeOwned`; the branch flag is
1142
+ // ours to set.
1143
+ const rollbackWarnings = await rollbackFailedCreation(workspace, {
1144
+ deleteLocalBranch: !useReusedWorktree,
1145
+ });
1146
+ // The setup script has already run by this point — see the caveat's own
1147
+ // comment for what that rollback cannot reach.
1148
+ const setupCaveat = setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : '';
1149
+ const failure = `Failed to start the agent: ${agentStartError}. The workspace, its worktree and its branch were removed.${setupCaveat}`;
1150
+ emitCreateFailed(creationId, 'start-agent', failure);
1151
+ return c.json({ error: failure, step: 'start-agent', rollback: { done: true, warnings: rollbackWarnings } }, 500);
1152
+ }
1153
+ // The agent is running. From here the workspace is a live object, not a
1154
+ // half-created one, so it stops being the rollback's business: dropping the
1155
+ // handle means a later throw (the read below, a header, a status beat)
1156
+ // returns 500 without demolishing a worktree that already has an agent
1157
+ // working in it. Failures before this line still roll back in full.
1158
+ createdWorkspace = null;
918
1159
  // Return created workspace with tasks
919
1160
  const workspaceWithTasks = workspaceService.getWorkspaceWithTasks(workspace.id);
920
1161
  if (workingBranchAdjusted) {
@@ -926,11 +1167,49 @@ Once the brainstorming + planning steps above are complete and you have a saved
926
1167
  if (usedLocalFallback) {
927
1168
  c.header('X-Kobo-Source-Fallback', 'local');
928
1169
  }
1170
+ // The `done` beat means "the sequence completed the way the user expects" —
1171
+ // NOT merely "the HTTP response is about to return 201". A workspace whose
1172
+ // setup script failed still gets a 201 (its record exists, in `error`
1173
+ // status, ready for retry via POST /:id/start) because that path has a
1174
+ // deliberate recovery flow — but the create-progress channel must not
1175
+ // claim success on it: the corresponding emitCreateFailed above already
1176
+ // named the real failure. A failed agent start never reaches this line at
1177
+ // all (see the rollback branch above).
1178
+ if (!setupScriptFailed) {
1179
+ currentStep = 'done';
1180
+ emitCreateProgress(creationId, currentStep);
1181
+ }
929
1182
  return c.json(workspaceWithTasks, 201);
930
1183
  }
931
1184
  catch (err) {
932
1185
  const message = err instanceof Error ? err.message : String(err);
933
- return c.json({ error: message }, 500);
1186
+ // Anything past `create-record` left a row behind. Leaving it there is a
1187
+ // half-created workspace whose existence lies to the user — and the client
1188
+ // already tells them "the server undoes everything it created". Make that
1189
+ // sentence true for every step, not only the two that had their own
1190
+ // handler. Best-effort: the rollback never throws and never becomes the
1191
+ // reported error.
1192
+ if (createdWorkspace) {
1193
+ emitCreateProgress(creationId, 'rollback');
1194
+ const rollbackWarnings = await rollbackFailedCreation(createdWorkspace, { deleteLocalBranch: createdBranch });
1195
+ logError('workspaces', 'Rolled back a creation that failed outside any per-step handler', {
1196
+ workspaceId: createdWorkspace.id,
1197
+ step: currentStep,
1198
+ error: message,
1199
+ ...(rollbackWarnings.length > 0 ? { rollbackWarnings } : {}),
1200
+ });
1201
+ // Say exactly what was undone — a claim of "everything" would be the
1202
+ // very kind of comfortable lie this route is being fixed for.
1203
+ const removed = createdBranch
1204
+ ? ' The workspace, its worktree and its branch were removed.'
1205
+ : ' The half-created workspace was removed.';
1206
+ const cause = message.endsWith('.') ? message : `${message}.`;
1207
+ const failure = `${cause}${removed}${setupScriptConfigured ? SETUP_SCRIPT_ROLLBACK_CAVEAT : ''}`;
1208
+ emitCreateFailed(creationId, currentStep, failure);
1209
+ return c.json({ error: failure, step: currentStep, rollback: { done: true, warnings: rollbackWarnings } }, 500);
1210
+ }
1211
+ emitCreateFailed(creationId, currentStep, message);
1212
+ return c.json({ error: message, step: currentStep }, 500);
934
1213
  }
935
1214
  });
936
1215
  // POST /api/workspaces/:id/sessions — create a new idle agent session
@@ -994,6 +1273,9 @@ app.get('/info', (c) => {
994
1273
  workspaces: workspaceService.listWorkspaces(false),
995
1274
  prSnapshots: getAllPrSnapshots(),
996
1275
  gitStats: getAllGitStats(),
1276
+ // In-memory truth, not the `status` column: a workspace missing from
1277
+ // this map has no agent, whatever its status claims.
1278
+ agentLiveness: agentManager.getAllAgentLiveness(),
997
1279
  });
998
1280
  }
999
1281
  catch (err) {
@@ -1419,7 +1701,15 @@ app.delete('/:id/events/:eventId', (c) => {
1419
1701
  return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
1420
1702
  }
1421
1703
  const db = getDb();
1704
+ // Capture the session BEFORE deleting: since v36 there is no AFTER DELETE
1705
+ // trigger, so the metrics of that session must be recomputed once, here.
1706
+ const row = db
1707
+ .prepare('SELECT session_id FROM ws_events WHERE id = ? AND workspace_id = ?')
1708
+ .get(eventId, workspaceId);
1422
1709
  db.prepare('DELETE FROM ws_events WHERE id = ? AND workspace_id = ?').run(eventId, workspaceId);
1710
+ if (row?.session_id) {
1711
+ workspaceService.recomputeSessionMetrics(workspaceId, row.session_id);
1712
+ }
1423
1713
  return c.json({ ok: true });
1424
1714
  }
1425
1715
  catch (err) {
@@ -1942,7 +2232,7 @@ app.get('/:id', (c) => {
1942
2232
  if (!workspace) {
1943
2233
  return c.json({ error: `Workspace '${id}' not found` }, 404);
1944
2234
  }
1945
- return c.json(workspace);
2235
+ return c.json({ ...workspace, agentLiveness: agentManager.getAgentLiveness(id) });
1946
2236
  }
1947
2237
  catch (err) {
1948
2238
  const message = err instanceof Error ? err.message : String(err);
@@ -2244,14 +2534,15 @@ app.post('/:id/run-setup-script', async (c) => {
2244
2534
  if (setupScriptRunning.has(id)) {
2245
2535
  return c.json({ error: 'Setup script is already running for this workspace' }, 409);
2246
2536
  }
2247
- // Stop the running agent before re-running the setup script
2537
+ // Stop the running agent before re-running the setup script — the script
2538
+ // rewrites the worktree the agent may still be writing to.
2248
2539
  try {
2249
2540
  if (agentManager.getAgentStatus(id)) {
2250
- agentManager.stopAgent(id);
2541
+ await agentManager.stopAgentAndWait(id);
2251
2542
  }
2252
2543
  }
2253
- catch {
2254
- /* best-effort — agent may already be stopped */
2544
+ catch (err) {
2545
+ console.error(`[workspaces] stopAgentAndWait before setup script failed for '${id}':`, err);
2255
2546
  }
2256
2547
  const effectiveSettings = settingsService.getEffectiveSettings(workspace.projectPath);
2257
2548
  if (!effectiveSettings.setupScript) {
@@ -2295,10 +2586,10 @@ app.post('/:id/archive', migrationGuard, async (c) => {
2295
2586
  return c.json({ error: 'Already archived' }, 400);
2296
2587
  }
2297
2588
  try {
2298
- agentManager.stopAgent(id);
2589
+ await agentManager.stopAgentAndWait(id);
2299
2590
  }
2300
- catch {
2301
- // Agent may not be running — ignore
2591
+ catch (err) {
2592
+ console.error(`[workspaces] stopAgentAndWait during archive failed for '${id}':`, err);
2302
2593
  }
2303
2594
  try {
2304
2595
  await devServerService.stopDevServer(id);
@@ -2377,12 +2668,14 @@ app.post('/:id/unarchive', migrationGuard, (c) => {
2377
2668
  // rather than thrown, so a bulk delete never aborts mid-batch. Returns the
2378
2669
  // list of user-facing warning messages (empty when everything was clean).
2379
2670
  async function deleteWorkspaceWithSideEffects(workspace, opts) {
2380
- // Stop agent if running (best-effort)
2671
+ // Stop the agent and WAIT for it to die. `git worktree remove --force` runs
2672
+ // a few lines below: firing and forgetting here pulled the directory from
2673
+ // under an agent still writing to it, destroying uncommitted work.
2381
2674
  try {
2382
- agentManager.stopAgent(workspace.id);
2675
+ await agentManager.stopAgentAndWait(workspace.id);
2383
2676
  }
2384
- catch {
2385
- // Agent may not be running — ignore
2677
+ catch (err) {
2678
+ console.error(`[workspaces] stopAgentAndWait during delete failed for '${workspace.name}':`, err);
2386
2679
  }
2387
2680
  // Stop dev server if it was running. The processSpawn would otherwise
2388
2681
  // outlive the workspace (and keep its port + docker containers alive).
@@ -2412,7 +2705,7 @@ async function deleteWorkspaceWithSideEffects(workspace, opts) {
2412
2705
  }
2413
2706
  else if (workspace.worktreeOwned) {
2414
2707
  try {
2415
- worktreeService.removeWorktree(workspace.projectPath, worktreePath);
2708
+ await worktreeService.removeWorktree(workspace.projectPath, worktreePath);
2416
2709
  }
2417
2710
  catch (err) {
2418
2711
  const message = err instanceof Error ? err.message : String(err);
@@ -2549,12 +2842,12 @@ app.post('/:id/start', migrationGuard, async (c) => {
2549
2842
  const prompt = body.prompt ?? pendingInitialPrompt ?? 'Continue the previous task where you left off.';
2550
2843
  const agentSessionId = body.agentSessionId;
2551
2844
  const resume = body.resume === true;
2552
- // Stop existing agent if running
2845
+ // Stop the existing agent and wait: a fresh one starts right below.
2553
2846
  try {
2554
- agentManager.stopAgent(id);
2847
+ await agentManager.stopAgentAndWait(id);
2555
2848
  }
2556
- catch {
2557
- // Agent may not be running — ignore
2849
+ catch (err) {
2850
+ console.error(`[workspaces] stopAgentAndWait before start failed for '${id}':`, err);
2558
2851
  }
2559
2852
  const worktreePath = workspace.worktreePath;
2560
2853
  const agent = agentManager.startAgent(id, worktreePath, prompt, workspace.model, resume, workspace.agentPermissionMode, agentSessionId, workspace.reasoningEffort);
@@ -2864,8 +3157,10 @@ app.post('/:id/rename-branch', async (c) => {
2864
3157
  if (!newName) {
2865
3158
  return c.json({ error: 'newName is required' }, 400);
2866
3159
  }
2867
- if (!/^[A-Za-z0-9/_\-.]+$/.test(newName)) {
2868
- return c.json({ error: 'Invalid branch name (only letters, digits, /, _, -, . allowed)' }, 400);
3160
+ if (!gitOps.isValidBranchName(newName)) {
3161
+ return c.json({
3162
+ error: 'Invalid branch name. It must start with a letter or a digit and may then contain letters, digits, /, _, - and . — no leading dash, no "..", no trailing "/", "." or ".lock".',
3163
+ }, 400);
2869
3164
  }
2870
3165
  const workspace = workspaceService.getWorkspace(id);
2871
3166
  if (!workspace) {
@@ -3419,7 +3714,17 @@ app.post('/:id/cancel-source-change', async (c) => {
3419
3714
  const workspace = workspaceService.getWorkspace(id);
3420
3715
  if (!workspace)
3421
3716
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3422
- const backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
3717
+ // "No backup exists" and "I could not find out" are different answers.
3718
+ // Conflating them told the user restoration was impossible while the
3719
+ // backup branch was on disk, closing the only rollback path there is.
3720
+ let backups;
3721
+ try {
3722
+ backups = gitOps.listBackupBranches(workspace.worktreePath, workspace.workingBranch);
3723
+ }
3724
+ catch (err) {
3725
+ const message = err instanceof Error ? err.message : String(err);
3726
+ return c.json({ error: `Cannot list backup branches: ${message}`, code: 'backup_list_failed' }, 500);
3727
+ }
3423
3728
  if (backups.length === 0) {
3424
3729
  return c.json({ error: 'No backup branch found — cannot auto-restore', code: 'no_backup' }, 409);
3425
3730
  }
@@ -3625,13 +3930,13 @@ app.post('/:id/start-review', async (c) => {
3625
3930
  let messageSent = false;
3626
3931
  let emitSessionId;
3627
3932
  if (newSession) {
3628
- // Stop current agent (best-effort) then start fresh.
3933
+ // Stop current agent, wait for it, then start fresh.
3629
3934
  try {
3630
- agentManager.stopAgent(workspace.id);
3935
+ await agentManager.stopAgentAndWait(workspace.id);
3631
3936
  }
3632
3937
  catch (err) {
3633
3938
  const msg = err instanceof Error ? err.message : String(err);
3634
- console.error(`[start-review] stopAgent failed (continuing): ${msg}`);
3939
+ console.error(`[start-review] stopAgentAndWait failed (continuing): ${msg}`);
3635
3940
  }
3636
3941
  try {
3637
3942
  const agent = agentManager.startAgent(workspace.id, worktreePath, rendered, workspace.model, false /* resume */, workspace.agentPermissionMode, undefined, workspace.reasoningEffort);
@@ -3807,7 +4112,7 @@ app.post('/:id/mark-read', (c) => {
3807
4112
  }
3808
4113
  });
3809
4114
  // POST /api/workspaces/:id/stop — stop agent
3810
- app.post('/:id/stop', migrationGuard, (c) => {
4115
+ app.post('/:id/stop', migrationGuard, async (c) => {
3811
4116
  try {
3812
4117
  const id = c.req.param('id');
3813
4118
  const workspace = workspaceService.getWorkspace(id);
@@ -3815,10 +4120,12 @@ app.post('/:id/stop', migrationGuard, (c) => {
3815
4120
  return c.json({ error: `Workspace '${id}' not found` }, 404);
3816
4121
  }
3817
4122
  try {
3818
- agentManager.stopAgent(id);
4123
+ // The response says "stopped": make that true before returning.
4124
+ await agentManager.stopAgentAndWait(id);
3819
4125
  }
3820
- catch {
4126
+ catch (err) {
3821
4127
  // Agent may not be tracked (e.g. server restarted) — just update status
4128
+ console.error(`[workspaces] stopAgentAndWait on manual stop failed for '${id}':`, err);
3822
4129
  }
3823
4130
  // Always transition to idle so the UI reflects the stopped state
3824
4131
  try {